Back to blog
Tutorials

OpenSearch dense vector fields: knn_vector explained for Elasticsearch users

OpenSearch has no dense_vector type. We converted an Elasticsearch 9.5.5 dense_vector mapping to knn_vector on OpenSearch 3.9.0, compared scores on both and recorded every error, including two ES parameters OpenSearch drops without telling you.

Abhilash B11 min read
#opensearch#elasticsearch#vector search#knn_vector#migration

OpenSearch has no dense_vector type; the equivalent is knn_vector, and the index needs index.knn: true for approximate search. An OpenSearch dense vector field takes dimension where Elasticsearch takes dims, and space_type where Elasticsearch takes similarity. The defaults differ too: OpenSearch defaults to l2 and Elasticsearch to cosine, so a mapping copied across without its metric changes your ranking.

We tested everything below on OpenSearch 3.9.0 (Lucene 10.5.1, k-NN plugin 3.9.0.0) and Elasticsearch 9.5.5 (Lucene 10.5.1), both single-node Docker containers with security off, as of 31 October 2026. The vectors are an illustrative set of four 4-dimension unit vectors, small enough to check every score by hand. Every request, response and error below is from those runs.

The minimal OpenSearch dense vector mapping

PUT docs
{
  "settings": { "index.knn": true },
  "mappings": { "properties": {
    "emb": { "type": "knn_vector", "dimension": 4 }
  } }
}

GET docs/_mapping returns only what you sent: {"type":"knn_vector","dimension":4}. The defaults that apply are listed in the knn_vector reference instead of the stored mapping: the faiss engine, the hnsw method, the l2 space and the float data type. OpenSearch also rejects ?include_defaults=true on _mapping with contains unrecognized parameter: [include_defaults], so the docs are the only place to check them.

The index.knn setting is fixed when the index is created. We mapped the same field on an index without it. The mapping was accepted and documents indexed, but the first knn query failed:

failed to create query: Field 'emb' is not built for ANN search.

Closing the index and setting it afterwards fails with final noknn setting [index.knn], not updateable. The only fix is a new index and a _reindex. Exact scoring still works on such an index (a script_score query with "lang": "knn" returned both documents), which makes index.knn: false the counterpart of Elasticsearch's "index": false.

Dynamic mapping is the other default Elasticsearch users rely on without noticing. Elasticsearch 9.5 maps an unmapped float array of 128 to 4096 elements to dense_vector with cosine. OpenSearch added the same idea in 3.9, but it is off until you set the knn.dynamic_mapping.enabled cluster setting. Per the docs, it then infers knn_vector only for arrays whose length is a multiple of 8 between 128 and 16,000, and the inferred field gets l2. It also does not turn on approximate search, so the index still needs index.knn: true at creation. We only read this part of the docs and did not test it. If your ingest relied on Elasticsearch's dynamic vector mapping, write explicit mappings or index templates for OpenSearch instead. An inferred l2 field under embeddings trained for cosine is the same silent metric change described below.

Every knn_vector parameter on 3.9

ParameterValuesDefaultNotes
dimension1 to 16,000Required16001 fails with Dimension value cannot be greater than 16000 for vector with engine: faiss
data_typefloat, half_float, byte, binaryfloathalf_float is new in 3.9. binary needs dimension divisible by 8
space_typel2, cosinesimil, innerproduct, l1, linf, hamming, hammingbitl2Top level or inside method, not both with different values
methodname, engine, space_type, parametershnsw on faissparameters holds m, ef_construction, ef_search, encoder
compression_level1x, 2x, 4x, 8x, 16x, 32x1x (in memory)Picks the quantizer, and with it the engine when you omit engine
modein_memory, on_diskin_memoryDeprecated in the server log on 3.9.0, see below
model_idID from the Train APINoneFor IVF and PQ. Replaces method and dimension

Two of these behave differently from what the docs pages suggest.

mode is still documented on the memory-optimized vectors page with no deprecation notice. On 3.9.0, creating a field with "mode": "on_disk" succeeded and wrote this to the node log:

[DEPRECATION][o.o.d.k.i.e.EngineResolver] The 'mode' parameter is deprecated starting with version 3.8.0. Mode is now derived from 'compression_level'. Explicitly setting 'mode' will be removed in a future release.

The change is k-NN PR #3436. For new mappings, set compression_level and leave mode out.

The docs also say you "cannot specify an encoder in the method mapping" when compression_level is set. On 3.9.0 a field with "compression_level": "4x" and a Lucene sq encoder with bits: 7 was accepted. It was a matching pair; we did not test a conflicting one. Pick one of the two styles per field and you avoid the question.

dense_vector to knn_vector: the parameter map

The dense_vector reference for 9.5 and the OpenSearch docs line up like this:

Elasticsearch dense_vectorOpenSearch knn_vectorNotes from our runs
dims (optional, max 4096)dimension (required, max 16,000)dims is rejected: unknown parameter [dims] on mapper [emb] of type [knn_vector]
element_type: floatdata_type: floatelement_type is rejected the same way
element_type: bfloat16data_type: half_floatDifferent 16-bit formats. FP16 rejects values outside ±65504
element_type: bytedata_type: byte300 is rejected: KNN vector values are not within in the byte range [-128, ...
element_type: bitdata_type: binary + space_type: hammingES bit only accepts similarity: l2_norm
similarity: cosinespace_type: cosinesimilSame scores, see below
similarity: l2_normspace_type: l2Same scores
similarity: dot_productspace_type: innerproductDifferent score scale
similarity: max_inner_productspace_type: innerproductOS does not require unit length
index: falseindex.knn: false (index setting)Exact search only
index_options.type: hnswmethod.name: hnsw
index_options.m, ef_constructionmethod.parameters.m, ef_constructionBoth default to 16 and 100
int8_hnswcompression_level: 4x (Lucene, 7-bit)Or Lucene encoder: sq, bits: 7
int4_hnswcompression_level: 8x (4-bit)Faiss or Lucene
bbq_hnswcompression_level: 32x (1-bit)Closest match; different quantizer
bbq_diskNo direct equivalentSee the BBQ HNSW vs DiskBBQ comparison
flat, int8_flatExact script_score queryThe 3.9 docs also mention a flat method; we did not test it
rescore_vector.oversamplerescore.oversample_factor, set per queryDefault 2.0 at 32x

The quantized rows map by compression ratio, which is how OpenSearch names them. The compression table on the memory-optimized page gives the bit widths: 4x is 7-bit on Lucene only, 8x is 4-bit and 32x is 1-bit. Equal ratios do not mean equal recall, because the quantizers are separate implementations. Measure recall on your own queries before you treat 32x as a stand-in for BBQ.

Two Elasticsearch parameters OpenSearch drops without telling you

dims and element_type fail loudly. similarity and index_options do not. This request returned 200 with "acknowledged": true and no Warning header:

PUT t4b
{
  "settings": { "index.knn": true },
  "mappings": { "properties": {
    "emb": { "type": "knn_vector", "dimension": 4,
             "similarity": "cosine", "index_options": { "type": "hnsw" } }
  } }
}

The stored mapping is {"type":"knn_vector","dimension":4}. Both parameters are gone, and the field is now l2 with default HNSW settings. The only trace is in the node log:

[DEPRECATION][o.o.d.i.m.ParametrizedFieldMapper] Parameter [similarity] has no effect on type [knn_vector] and will be removed in future
[DEPRECATION][o.o.d.i.m.ParametrizedFieldMapper] Parameter [index_options] has no effect on type [knn_vector] and will be removed in future

The warning comes from OpenSearch's generic ParametrizedFieldMapper, which tolerates these two names on a field type that does not define them, so a half-converted Elasticsearch mapping parses cleanly. With cosine embeddings, l2 ranks unit-length vectors in the same order, since squared distance is 2 - 2cos for them. Non-normalized vectors can rank differently, and every score changes. After any conversion, read the mapping back with GET <index>/_mapping and confirm space_type is there.

sequenceDiagram
  participant C as Migration script
  participant O as OpenSearch 3.9.0
  participant L as Node log
  C->>O: PUT index with dims
  O-->>C: 400 unknown parameter dims
  C->>O: PUT index with dimension, similarity, index_options
  O->>L: DEPRECATION has no effect on type knn_vector
  O-->>C: 200 acknowledged
  C->>O: GET _mapping
  O-->>C: type knn_vector, dimension 4, no space_type
  Note over C,O: Field is l2 with default HNSW.<br/>Only the log shows what was dropped.

Scores: two metrics match, one does not

We indexed the same four vectors into both engines for each metric pair, ran the same query vector and recorded the scores. Elasticsearch used "index_options": {"type": "hnsw"} and OpenSearch used the Faiss default, so neither side quantized.

DocDot product with queryES cosineOS cosinesimilES l2_normOS l2ES dot_productOS innerproduct
a0.99410.9970.9970.98830.98830.9971.9941
b0.67250.83620.83620.60420.60420.83621.6725
c0.09300.54650.54650.35540.35540.54651.093
d-0.94580.02710.02710.20440.20440.02710.5139

The ranking is identical in all six columns. The last two columns use different scales. Elasticsearch scores dot_product as (1 + dot) / 2, so its scores stay between 0 and 1. The OpenSearch spaces page defines innerproduct on Faiss as score = dot + 1 for a positive dot product and 1 / (1 + |dot|) for a negative one. Doc d checks out: 1 / (1 + 0.9458) = 0.5139.

Any absolute threshold breaks when you move a dot_product field to innerproduct. A min_score: 0.8 tuned on Elasticsearch keeps everything with a positive dot product on OpenSearch. Hybrid queries that min-max normalize each sub-query absorb the change; a weighted sum without normalization does not. Our hybrid search scoring explainer covers how each normalizer reacts to that. If your vectors are unit length, mapping dot_product to cosinesimil keeps the Elasticsearch scale exactly, at the cost of Faiss normalizing each vector at index time.

Two more differences come up during migration. Elasticsearch rejects a non-unit vector on a dot_product field with The [dot_product] similarity can only be used with unit-length vectors. OpenSearch innerproduct accepted [1,1,1,1] without complaint, so a normalization bug upstream shows up as odd rankings instead of indexing errors. The knn_vector docs also note that Faiss with cosinesimil stores normalized vectors, which is what _source returns when derived source is on.

Converting a real Elasticsearch mapping

The source mapping, on Elasticsearch 9.5.5 (we used dims: 4 to keep the outputs readable; a 384 or 768 model changes only that number):

PUT products
{
  "mappings": { "properties": {
    "title":    { "type": "text" },
    "category": { "type": "keyword" },
    "embedding": { "type": "dense_vector", "dims": 4, "similarity": "dot_product",
      "index_options": { "type": "int8_hnsw", "m": 32, "ef_construction": 200 } }
  } }
}

A literal translation to a Lucene sq encoder fails on 3.9.0:

Validation Failed: 1: Parameter [bits] is required for encoder [sq] on indices created with version 3.6.0 or later. Supported values: [2, 1, 7, 4];

With bits: 7 it is accepted. The shorter form uses compression_level, and OpenSearch picks Lucene because 4x is Lucene only:

PUT products
{
  "settings": { "index.knn": true },
  "mappings": { "properties": {
    "title":    { "type": "text" },
    "category": { "type": "keyword" },
    "embedding": { "type": "knn_vector", "dimension": 4, "space_type": "innerproduct",
      "compression_level": "4x",
      "method": { "name": "hnsw", "parameters": { "m": 32, "ef_construction": 200 } } }
  } }
}
{"products":{"mappings":{"properties":{"embedding":{"type":"knn_vector","dimension":4,
  "method":{"engine":"lucene","space_type":"innerproduct","name":"hnsw",
            "parameters":{"ef_construction":200,"m":32}},
  "compression_level":"4x","space_type":"innerproduct"}}}}}

Forcing "engine": "faiss" with 4x fails with "faiss" with "float" data type does not support "4x" compression. If you want Faiss, use 2x (16-bit) or 8x.

flowchart TD
  A["ES dense_vector field"] --> B{"element_type?"}
  B -- "float or bfloat16" --> C{"index_options.type"}
  B -- byte --> D["data_type: byte"]
  B -- bit --> E["data_type: binary<br/>space_type: hamming"]
  C -- hnsw --> F["method hnsw, faiss<br/>copy m and ef_construction"]
  C -- int8_hnsw --> G["compression_level: 4x<br/>Lucene, 7-bit"]
  C -- int4_hnsw --> H["compression_level: 8x"]
  C -- "bbq_hnsw or bbq_disk" --> I["compression_level: 32x<br/>measure recall first"]
  F --> J{"similarity"}
  G --> J
  H --> J
  I --> J
  D --> J
  J -- dot_product --> K["innerproduct, retune thresholds<br/>or cosinesimil to keep the scale"]
  J -- "cosine or l2_norm" --> L["cosinesimil or l2"]
  K --> M["GET _mapping and<br/>confirm space_type"]
  L --> M

Queries need rewriting too. The Elasticsearch top-level knn section fails on OpenSearch with Unknown key for a START_OBJECT in [knn]. The OpenSearch form goes inside query, keyed by field name, and takes vector and k:

POST products/_search
{
  "size": 4,
  "query": { "knn": { "embedding": {
    "vector": [0.988936, 0.098894, 0.049447, 0.098894], "k": 4,
    "method_parameters": { "ef_search": 100 } } } }
}

num_candidates does not exist on OpenSearch ([knn] unknown field [num_candidates]). The nearest equivalent on HNSW is method_parameters.ef_search.

Common errors and what they mean

Error on OpenSearch 3.9.0CauseFix
No handler for type [dense_vector] declared on field [emb]ES type name"type": "knn_vector"
unknown parameter [dims] on mapper [emb] of type [knn_vector]ES parameter namedimension
Vector dimension mismatch. Expected: 4, Given: 3Wrong embedding model or truncated vectorCheck the model's output size
Field 'emb' is not built for ANN search.Index created without index.knn: trueNew index, _reindex
Unable to find space: cosine . Valid values are: [l2, cosinesimil, l1, linf, innerproduct, hamming]ES metric name in space_typecosinesimil
"hnsw" with "faiss" configuration does not support space type: "l1".Metric the engine lacksl2, or exact search
Space type [hamming] is not supported with [float] data typehamming on floatsdata_type: binary
Cannot specify conflicting space types: "[innerproduct]" "[l2]"Different space_type at top level and in methodSet it once
Dimension should be multiply of 8 for binary vector data typeBinary dimension counts bitsUse a multiple of 8

The dimension mismatch error from Elasticsearch reads has a different number of dimensions [3] than defined in the mapping [4], so log alerts written for one engine will not match the other.

Our recommendation

Write the OpenSearch mapping by hand from the table above. Set index.knn: true and space_type explicitly on every field, and use compression_level for quantization. Do not run a find-and-replace on dense_vector, because the two parameters OpenSearch drops silently are exactly the ones that change ranking. Then check the result with GET _mapping, and run recall@10 against exact script_score on a sample of real queries before you cut over. The nmslib to Faiss migration guide has the recall script and the alias-swap cutover, and the vector sizing calculator estimates memory for each compression_level. If you are still choosing between the engines, start with the Elasticsearch vs OpenSearch comparison and the Elasticsearch to OpenSearch migration guide.

Where we can help

We move vector workloads between Elasticsearch and OpenSearch, from the mapping conversion to the recall check against real queries before cutover. See our OpenSearch consulting service.

Advisor, Query Quotient

Abhilash B is an advisor at Query Quotient and an information retrieval and AI expert who holds patents in the field. Abhilash brings that retrieval depth to the search, RAG and agent systems Query Quotient builds for clients.

Keep reading

Building something similar?

Tell us what you are working on. In a free 30-minute call we will give you an honest view of the approach, the effort and how to measure success.