Lab K3: The knn_vector Field Type
Background
Every k-NN index begins with one mapping decision: a field of type knn_vector. That single
type declaration is where the plugin's most consequential choices are made — the
dimension, the distance space, the engine (faiss / lucene / nmslib), the
algorithm (hnsw / ivf), the data type (float / byte / binary), and the on-disk
mode/compression story. Get the mapping right and indexing and querying "just work"; get
it wrong and you get rejected documents, the wrong engine, exact scans instead of ANN, or a
node that OOMs on native memory. The class that owns all of this is
KNNVectorFieldMapper (with its companion KNNVectorFieldType), and reading it is the
fastest way to understand how a high-level mapping JSON turns into low-level engine behavior.
In this Build-It lab you first read the mapper deeply — the ParametrizedFieldMapper
Builder and its Parameter<> fields, the validation that happens at mapping time, and the
wiring from the mapping into the KNNEngine and the custom codec. Then you make a small,
real change: you add a validation guard to the dimension parameter (reject dimensions
above a configurable ceiling with a clear error), and you write a KNNVectorFieldMapperTests-style
unit test that proves it. The change is illustrative — the value is in landing a real edit
in a real mapper and testing it the way the project does.
Note: The cluster manager (formerly master) publishes the index metadata that includes this mapping, but the mapper itself runs on the data nodes that index and search the field. Mapping validation happens when the mapping is parsed (on the node creating or updating the index); per-document parsing happens on the primary shard. Keep those two moments distinct — your validation guard fires at the first.
Read these alongside the lab:
- k-NN architecture — where the field mapper sits in the index path and how it reaches the codec.
- Engines — the faiss/lucene/nmslib capability fork your mapping selects.
- Mapping and analysis — the core
ParametrizedFieldMapper/Mapper/FieldMappermachinery k-NN's mapper is built on; this lab does not re-derive it.
Why This Lab Matters for Contributors
- Mapping bugs and feature requests are a steady stream of k-NN issues ("dimension X
rejected", "why can't I use filter with this engine", "add a new compression level"). All
of them route through
KNNVectorFieldMapper. Knowing it makes those issues legible. - The mapper is the single point where a user's intent (engine, space, method, data type,
mode) is validated against what the chosen
KNNEnginesupports — exactly the kind of small, well-scoped change a new contributor can own. - Writing a
ParametrizedFieldMapperBuilderwithParameter<>validators is a transferable OpenSearch skill — every field type in core and in plugins uses this exact pattern. You learn it once, on the most interesting field type in the ecosystem. - The project's mapper-test idiom (
TypeParser.parse(...)+expectThrows) is how you prove a mapping change without booting a cluster. It is fast, deterministic, and reviewer-friendly.
Prerequisites
- A from-source k-NN build (Lab K1);
./gradlew testworks. - You have traced the query path (Lab K2) and read k-NN architecture.
- Comfort with the core mapping & analysis deep
dive — specifically the
ParametrizedFieldMapper/Parametermodel.
cd ~/src/oss-repos/k-NN
ls src/main/java/org/opensearch/knn/index/mapper/
# KNNVectorFieldMapper.java KNNVectorFieldType.java KNNMappingConfig.java
# EngineFieldMapper.java FlatVectorFieldMapper.java ModelFieldMapper.java
# CompressionLevel.java Mode.java VectorValidator.java PerDimensionValidator.java ...
Step-by-Step Tasks
Step 1: Map the mapping parameters to the source
The knn_vector field type accepts a fixed set of mapping parameters. Find where each is
declared — they are Parameter<> fields on KNNVectorFieldMapper.Builder.
grep -n "class Builder\|Parameter<\|KNNConstants\.\|restrictedStringParam\|stringParam" \
src/main/java/org/opensearch/knn/index/mapper/KNNVectorFieldMapper.java | head -40
# The constant names the parameters use:
grep -n "DIMENSION\|KNN_METHOD\|KNN_ENGINE\|VECTOR_DATA_TYPE_FIELD\|MODE_PARAMETER\|COMPRESSION_LEVEL\|MODEL_ID\|TOP_LEVEL_PARAMETER_SPACE_TYPE" \
src/main/java/org/opensearch/knn/common/KNNConstants.java | head
You should be able to reconstruct this table from the source (the Builder.getParameters()
list — there are 11, confirmed by testBuilder_getParameters):
| Mapping param | Constant / Parameter field | What it controls | Engine behavior it drives |
|---|---|---|---|
dimension | KNNConstants.DIMENSION → dimension | vector length; validated > 0; must match every doc and the query | graph node arity; rejects mismatched vectors at parse time |
space_type | TOP_LEVEL_PARAMETER_SPACE_TYPE (or under method) | distance metric (l2/cosinesimil/innerproduct/l1/linf/hamming) | which SpaceType distance the engine computes; hamming only for binary |
method.name | KNN_METHOD → knnMethodContext | hnsw or ivf | graph (HNSW) vs inverted-list (IVF, needs training) |
method.engine | KNN_ENGINE (or top-level engine) | faiss / lucene / nmslib | native off-heap (faiss/nmslib) vs Lucene on-heap; capability set |
method.parameters | m, ef_construction, ef_search, nlist/nprobes | algorithm tuning | recall/latency/memory trade-off |
data_type | VECTOR_DATA_TYPE_FIELD → vectorDataType | float (default) / byte / binary | per-element width; binary enables hamming |
mode | MODE_PARAMETER → mode | in_memory / on_disk | selects the disk-ANN path + a default compression |
compression_level | COMPRESSION_LEVEL_PARAMETER → compressionLevel | 1x/2x/4x/8x/16x/32x | quantization aggressiveness for on_disk |
model_id | MODEL_ID → modelId | trained-model reference (IVF/PQ) | field inherits method from a trained model instead of inline method |
Note:
space_typeandenginecan appear either at the top level of the mapping or nested undermethod— k-NN supports both spellings (thetopLevelSpaceType/topLevelEngineparameters). GrepTOP_LEVEL_PARAMETER_SPACE_TYPE; it matters when you read validation, because both spellings must be reconciled.
Step 2: Read the validation — where bad mappings are rejected
The mapper validates the combination of parameters against the chosen engine's capabilities. This is the heart of the field type and where most of its bugs live.
# Per-element validators (e.g. byte range, fp16 range):
grep -n "class\|validate\|FloatVectorValidator\|ByteVectorValidator" \
src/main/java/org/opensearch/knn/index/mapper/PerDimensionValidator.java \
src/main/java/org/opensearch/knn/index/mapper/VectorValidator.java
# Whole-vector validators (e.g. space-specific constraints):
grep -n "class\|validateVector" \
src/main/java/org/opensearch/knn/index/mapper/SpaceVectorValidator.java
# Engine-capability checks at build time (e.g. nmslib has no filter, lucene/ivf rules):
grep -rn "validateMethod\|isTrainingRequired\|validate\|UnsupportedOperationException\|addValidationError" \
src/main/java/org/opensearch/knn/index/mapper/KNNVectorFieldMapper.java | head -20
The dimension parameter is the cleanest example. Read its declaration — a Parameter<Integer>
whose (name, context, value) -> { ... } lambda parses and validates:
grep -n "Parameter<Integer> dimension" src/main/java/org/opensearch/knn/index/mapper/KNNVectorFieldMapper.java
// src/main/java/org/opensearch/knn/index/mapper/KNNVectorFieldMapper.java (illustrative — grep for the real block)
protected final Parameter<Integer> dimension = new Parameter<>(
KNNConstants.DIMENSION,
false,
() -> UNSET_MODEL_DIMENSION_IDENTIFIER,
(n, c, o) -> { // n = name, c = context, o = raw value
if (o == null) {
throw new IllegalArgumentException("Dimension cannot be null");
}
int value;
try {
value = XContentMapValues.nodeIntegerValue(o);
} catch (Exception exception) {
throw new IllegalArgumentException(
String.format(Locale.ROOT, "Unable to parse [dimension] from provided value [%s] for vector [%s]", o, name));
}
if (value <= 0) { // <-- the existing lower-bound guard
throw new IllegalArgumentException(
String.format(Locale.ROOT, "Dimension value must be greater than 0 for vector: %s", name));
}
return value;
},
m -> toType(m).originalMappingParameters.getDimension()
);
This lower-bound guard is exactly the shape of the change you will make: add an upper-bound guard beside it.
Step 3: Wire the mapping to the engine and codec (read-only)
Before changing anything, confirm how the validated mapping reaches the engine and the codec — this is what your validation is protecting.
# The field type holds the resolved mapping config used at index/query time:
grep -n "class KNNVectorFieldType\|getKnnMappingConfig\|KNNMappingConfig\|getKnnEngine\|SpaceType" \
src/main/java/org/opensearch/knn/index/mapper/KNNVectorFieldType.java \
src/main/java/org/opensearch/knn/index/mapper/KNNMappingConfig.java | head
# The engine the mapping selects (faiss/lucene/nmslib) and its capabilities:
grep -rn "enum KNNEngine\|FAISS\|LUCENE\|NMSLIB\|getMaxDimension\|supports" \
src/main/java/org/opensearch/knn/index/engine/KNNEngine.java | head
# How the codec gets the native-vs-lucene format from the engine (the index path):
grep -rn "NativeEngines990KnnVectorsFormat\|KnnVectorsFormat\|getVectorsFormat\|EngineFieldMapper" \
src/main/java/org/opensearch/knn/index/codec/ \
src/main/java/org/opensearch/knn/index/mapper/EngineFieldMapper.java | head
The picture: KNNVectorFieldMapper.Builder validates → produces a KNNVectorFieldType
holding a KNNMappingConfig (dimension, SpaceType, KNNEngine, method/model) → at
flush/merge the engine selects the vectors format (native NativeEngines990KnnVectorsFormat
for faiss/nmslib, Lucene's own format for lucene) → the graph is written as segment files.
Your validation runs before any of this, at mapping-parse time, so a bad dimension never
reaches the engine.
Map params → engine behavior, the version you should be able to defend:
| Mapping choice | faiss | lucene | nmslib (deprecated) |
|---|---|---|---|
| graph storage | native off-heap via k-NN codec | Lucene .vec/.vex/.vem on heap/mmap | native off-heap via k-NN codec |
method.name | hnsw, ivf | hnsw only | hnsw only |
filter support | yes (efficient filtering) | yes | no |
data_type | float, byte, binary | float, byte | float |
on_disk / compression | yes (PQ/BQ/SQ, rescoring) | scalar quant (Lucene SQ) | no |
training (model_id) | yes (IVF/PQ) | no | no |
Step 4: Make the change — add an upper-bound dimension guard
Add a configurable maximum-dimension check beside the existing value <= 0 guard. The
ceiling should be read from a constant so it is one obvious place to change, and the error
must name the field and the limit (good mapper errors are specific). First, find the real
site:
grep -n "Dimension value must be greater than 0" \
src/main/java/org/opensearch/knn/index/mapper/KNNVectorFieldMapper.java
Add a constant (near the other KNNConstants, or as a static final on the mapper — grep to
match the project's placement convention; an illustrative inline constant is shown here):
// In KNNVectorFieldMapper (or KNNConstants — match the project's convention).
// Illustrative ceiling; real engine maxima live on KNNEngine (grep getMaxDimension).
public static final int LAB_MAX_DIMENSION = 16_000;
Then extend the dimension parameter's validation lambda, immediately after the existing
lower-bound check:
if (value <= 0) {
throw new IllegalArgumentException(
String.format(Locale.ROOT, "Dimension value must be greater than 0 for vector: %s", name));
}
// --- Lab K3 addition: upper-bound guard ---
if (value > LAB_MAX_DIMENSION) {
throw new IllegalArgumentException(
String.format(
Locale.ROOT,
"Dimension value [%d] exceeds the maximum supported dimension [%d] for vector: %s",
value, LAB_MAX_DIMENSION, name));
}
// --- end addition ---
return value;
Warning: This is deliberately illustrative. In a real PR you would not hardcode a ceiling — you would derive it from the chosen
KNNEngine(each engine has its own maximum;grep getMaxDimension src/main/java/org/opensearch/knn/index/engine/KNNEngine.java), because faiss, lucene, and nmslib do not share a limit. Note that as the production-correct version in your PR description, and reference the per-engine maxima. The point of the lab is the mechanics of adding and testing a mapper validation, not this specific number.
Step 5: Write the unit test — KNNVectorFieldMapperTests-style
The project tests the mapper without a cluster: build a mapping with XContentBuilder, run
it through KNNVectorFieldMapper.TypeParser.parse(...), and assert. Use expectThrows for
the rejection path. Add this to
src/test/java/org/opensearch/knn/index/mapper/KNNVectorFieldMapperTests.java:
public void testDimension_aboveMaximum_thenThrows() throws IOException {
ModelDao modelDao = mock(ModelDao.class);
KNNVectorFieldMapper.TypeParser typeParser = new KNNVectorFieldMapper.TypeParser(() -> modelDao);
// A mapping with a dimension just over the lab ceiling.
XContentBuilder tooBig = XContentFactory.jsonBuilder()
.startObject()
.field(TYPE_FIELD_NAME, KNN_VECTOR_TYPE)
.field(DIMENSION_FIELD_NAME, KNNVectorFieldMapper.LAB_MAX_DIMENSION + 1)
.endObject();
IllegalArgumentException ex = expectThrows(
IllegalArgumentException.class,
() -> typeParser.parse(
TEST_FIELD_NAME,
xContentBuilderToMap(tooBig),
buildParserContext(TEST_INDEX_NAME, settings) // settings is the test base helper
)
);
assertTrue(ex.getMessage(), ex.getMessage().contains("exceeds the maximum supported dimension"));
}
public void testDimension_atMaximum_thenSucceeds() throws IOException {
ModelDao modelDao = mock(ModelDao.class);
KNNVectorFieldMapper.TypeParser typeParser = new KNNVectorFieldMapper.TypeParser(() -> modelDao);
// Exactly at the ceiling must still parse (boundary case).
XContentBuilder atMax = XContentFactory.jsonBuilder()
.startObject()
.field(TYPE_FIELD_NAME, KNN_VECTOR_TYPE)
.field(DIMENSION_FIELD_NAME, KNNVectorFieldMapper.LAB_MAX_DIMENSION)
.endObject();
KNNVectorFieldMapper.Builder builder = (KNNVectorFieldMapper.Builder) typeParser.parse(
TEST_FIELD_NAME,
xContentBuilderToMap(atMax),
buildParserContext(TEST_INDEX_NAME, settings)
);
assertEquals(KNNVectorFieldMapper.LAB_MAX_DIMENSION, (int) builder.getOriginalParameters().getDimension());
}
Note: Helpers like
buildParserContext, thesettingsfield,xContentBuilderToMap, andgetOriginalParameters()come from the existing test class (extends KNNTestCase). Copy the current signatures from an existingtestTypeParser_*test rather than trusting these; the base class isKNNTestCase, not the genericOpenSearchTestCase.
Step 6: Build, run the test, and verify against a live node
# Compile + run only your new tests (fast inner loop):
./gradlew test --tests "org.opensearch.knn.index.mapper.KNNVectorFieldMapperTests" \
--tests "*testDimension_aboveMaximum_thenThrows" \
--tests "*testDimension_atMaximum_thenSucceeds"
# Run the whole mapper test class to confirm you didn't break a sibling test:
./gradlew test --tests "org.opensearch.knn.index.mapper.KNNVectorFieldMapperTests"
Then prove it end to end on a node (./gradlew run with your change):
# Rejected: dimension over the ceiling -> 400 with your specific message.
curl -s -XPUT 'localhost:9200/dim_too_big?pretty' -H 'Content-Type: application/json' -d '{
"settings": { "index.knn": true },
"mappings": { "properties": {
"v": { "type": "knn_vector", "dimension": 16001,
"method": { "name": "hnsw", "engine": "faiss", "space_type": "l2" } }
}}
}'
# Expect: "exceeds the maximum supported dimension [16000]"
# Accepted: a normal dimension still works.
curl -s -XPUT 'localhost:9200/dim_ok?pretty' -H 'Content-Type: application/json' -d '{
"settings": { "index.knn": true },
"mappings": { "properties": {
"v": { "type": "knn_vector", "dimension": 128,
"method": { "name": "hnsw", "engine": "faiss", "space_type": "l2" } }
}}
}'
# Expect: "acknowledged": true
Step 7: Precommit and a clean commit
./gradlew spotlessApply
./gradlew precommit
git checkout -b lab/k3-dimension-guard
git add src/main/java/org/opensearch/knn/index/mapper/KNNVectorFieldMapper.java \
src/test/java/org/opensearch/knn/index/mapper/KNNVectorFieldMapperTests.java
git commit -s -m "Lab K3: add upper-bound dimension validation to knn_vector mapper"
Implementation Requirements / Deliverables
-
You can reconstruct the
knn_vectorparameter table fromBuilder.getParameters()(all 11 params) and explain what each one drives in the engine. -
The new upper-bound guard added to the
dimensionParametervalidation lambda, with a specific, field-naming error message. -
A
KNNVectorFieldMapperTeststest for the reject path (expectThrows,IllegalArgumentException) and the boundary accept path. -
./gradlew test --tests "*KNNVectorFieldMapperTests"passes, including your new tests and all pre-existing ones. - A live node rejects an over-ceiling mapping with your message and accepts a normal one.
-
precommitpasses; a DCO-signed commit; SPDX headers intact on every touched file. -
In your write-up: the production-correct version of this change (per-engine
getMaxDimension) and why hardcoding a single ceiling is wrong.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
cannot find symbol: LAB_MAX_DIMENSION in the test | constant placed where the test can't see it | make it public static final on KNNVectorFieldMapper (or import its real location) |
Test compiles but buildParserContext/settings/xContentBuilderToMap unresolved | those are helpers on KNNTestCase/the test class, signatures differ in your version | copy the exact idiom from an existing testTypeParser_* test in the same file |
| Your guard never fires | you edited the wrong validation lambda, or the value is parsed elsewhere first | confirm you edited the dimension Parameter<Integer> block; grep "greater than 0" to find the real site |
MapperParsingException instead of IllegalArgumentException | the parser wraps your IAE | assert on the wrapped cause/message, or expectThrows(MapperParsingException.class, ...) and check getCause() |
model_id mappings skip your check | model-based fields take dimension from the trained model (UNSET_MODEL_DIMENSION_IDENTIFIER) | that's expected — dimension is unset inline for model fields; note it as a known gap |
precommit fails on license headers | new constant block lacks SPDX context (usually fine inside an existing file) | run ./gradlew spotlessApply; ensure the file still has its header |
| Live node still accepts a big dimension | you ran an old build | rebuild (./gradlew run restarts the node with your change) |
Expected Output
# Unit tests
> Task :test
KNNVectorFieldMapperTests > testDimension_aboveMaximum_thenThrows PASSED
KNNVectorFieldMapperTests > testDimension_atMaximum_thenSucceeds PASSED
BUILD SUCCESSFUL
# Live reject (dimension 16001)
{
"error": {
"root_cause": [ { "type": "illegal_argument_exception",
"reason": "Dimension value [16001] exceeds the maximum supported dimension [16000] for vector: v" } ],
"type": "illegal_argument_exception"
},
"status": 400
}
# Live accept (dimension 128)
{ "acknowledged": true, "shards_acknowledged": true, "index": "dim_ok" }
Stretch Goals
- Make it per-engine and correct. Replace
LAB_MAX_DIMENSIONwith the chosenKNNEngine's real maximum (grep getMaxDimensiononKNNEngine), so faiss, lucene, and nmslib each enforce their own ceiling. Update the error to name the engine, and add a test per engine. - Validate a parameter combination. Add a check that rejects
space_type: hammingunlessdata_type: binary(grepSpaceType/VectorDataTypeto find where these meet), with a test. This teaches cross-parameter validation, the harder and more common case. - Trace the value into the codec. With your guard in place, index a vector and confirm
(via Lab K2's technique) that the validated dimension flows
into
KNNMappingConfigand the vectors format. Where would a wrong dimension have surfaced if validation hadn't caught it? - Add a deprecation-style warning. Instead of rejecting, emit a deprecation log for a
discouraged-but-allowed dimension range, using the deprecation logger (grep core for
DeprecationLogger). Note when a warning beats a hard error. - Write a YAML REST test. Add a
do: indices.create+catch: bad_requesttest undersrc/yamlRestTest/...asserting the over-ceiling mapping is rejected, and run theyamlRestTesttask.
Validation / Self-check
- List the
knn_vectormapping parameters and, for each, name theKNNEnginebehavior it drives. Which two can be written either top-level or nested undermethod, and why does that matter for validation? - What is the difference between mapping-time validation and per-document parsing in
KNNVectorFieldMapper? On which node does each happen, and which one did your guard touch? - Walk the path from a validated mapping to a written graph:
Builder→KNNVectorFieldType/KNNMappingConfig→ engine → vectors format → segment files. Where does faiss diverge from lucene? - Why is hardcoding a single
LAB_MAX_DIMENSIONthe wrong production design, and what is the correct source of the limit? - In the unit test, what does
KNNVectorFieldMapper.TypeParser.parse(...)give you that a livecurlmapping does not, and why isexpectThrowsthe right assertion here? - How does a
model_idfield get its dimension, and why does your inline-dimension guard not apply to it? - Name one cross-parameter constraint the mapper enforces (e.g. engine vs filter, space vs data_type) and where in the source it lives.
When your guard rejects bad dimensions with a clear message, your KNNVectorFieldMapperTests
tests pass, and you can defend the per-engine-correct version of the change, you understand
the knn_vector field type from mapping JSON to engine behavior. Revisit
k-NN architecture for how the mapper sits in the index path, and
engines for the capability fork your mapping selects; the core machinery your
mapper is built on is in mapping and analysis.