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.
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
| Parameter | Values | Default | Notes |
|---|---|---|---|
dimension | 1 to 16,000 | Required | 16001 fails with Dimension value cannot be greater than 16000 for vector with engine: faiss |
data_type | float, half_float, byte, binary | float | half_float is new in 3.9. binary needs dimension divisible by 8 |
space_type | l2, cosinesimil, innerproduct, l1, linf, hamming, hammingbit | l2 | Top level or inside method, not both with different values |
method | name, engine, space_type, parameters | hnsw on faiss | parameters holds m, ef_construction, ef_search, encoder |
compression_level | 1x, 2x, 4x, 8x, 16x, 32x | 1x (in memory) | Picks the quantizer, and with it the engine when you omit engine |
mode | in_memory, on_disk | in_memory | Deprecated in the server log on 3.9.0, see below |
model_id | ID from the Train API | None | For 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_vector | OpenSearch knn_vector | Notes 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: float | data_type: float | element_type is rejected the same way |
element_type: bfloat16 | data_type: half_float | Different 16-bit formats. FP16 rejects values outside ±65504 |
element_type: byte | data_type: byte | 300 is rejected: KNN vector values are not within in the byte range [-128, ... |
element_type: bit | data_type: binary + space_type: hamming | ES bit only accepts similarity: l2_norm |
similarity: cosine | space_type: cosinesimil | Same scores, see below |
similarity: l2_norm | space_type: l2 | Same scores |
similarity: dot_product | space_type: innerproduct | Different score scale |
similarity: max_inner_product | space_type: innerproduct | OS does not require unit length |
index: false | index.knn: false (index setting) | Exact search only |
index_options.type: hnsw | method.name: hnsw | |
index_options.m, ef_construction | method.parameters.m, ef_construction | Both default to 16 and 100 |
int8_hnsw | compression_level: 4x (Lucene, 7-bit) | Or Lucene encoder: sq, bits: 7 |
int4_hnsw | compression_level: 8x (4-bit) | Faiss or Lucene |
bbq_hnsw | compression_level: 32x (1-bit) | Closest match; different quantizer |
bbq_disk | No direct equivalent | See the BBQ HNSW vs DiskBBQ comparison |
flat, int8_flat | Exact script_score query | The 3.9 docs also mention a flat method; we did not test it |
rescore_vector.oversample | rescore.oversample_factor, set per query | Default 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.
| Doc | Dot product with query | ES cosine | OS cosinesimil | ES l2_norm | OS l2 | ES dot_product | OS innerproduct |
|---|---|---|---|---|---|---|---|
| a | 0.9941 | 0.997 | 0.997 | 0.9883 | 0.9883 | 0.997 | 1.9941 |
| b | 0.6725 | 0.8362 | 0.8362 | 0.6042 | 0.6042 | 0.8362 | 1.6725 |
| c | 0.0930 | 0.5465 | 0.5465 | 0.3554 | 0.3554 | 0.5465 | 1.093 |
| d | -0.9458 | 0.0271 | 0.0271 | 0.2044 | 0.2044 | 0.0271 | 0.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 --> MQueries 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.0 | Cause | Fix |
|---|---|---|
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 name | dimension |
Vector dimension mismatch. Expected: 4, Given: 3 | Wrong embedding model or truncated vector | Check the model's output size |
Field 'emb' is not built for ANN search. | Index created without index.knn: true | New index, _reindex |
Unable to find space: cosine . Valid values are: [l2, cosinesimil, l1, linf, innerproduct, hamming] | ES metric name in space_type | cosinesimil |
"hnsw" with "faiss" configuration does not support space type: "l1". | Metric the engine lacks | l2, or exact search |
Space type [hamming] is not supported with [float] data type | hamming on floats | data_type: binary |
Cannot specify conflicting space types: "[innerproduct]" "[l2]" | Different space_type at top level and in method | Set it once |
Dimension should be multiply of 8 for binary vector data type | Binary dimension counts bits | Use 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.
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.
