k-NN Plugin Architecture
The warm-up showed you k-NN from the outside — a field type, a query, a
warmup call, some off-heap memory. This chapter is the wiring diagram. It answers: how
does one KNNPlugin class register a field type, a query, a custom Lucene codec, a
script engine, a system index, a circuit breaker, and a JNI bridge to C++ — and how do
those pieces hand a vector from a curl request all the way down to faiss and back?
You already know the plugin architecture in the
abstract: a Plugin subclass implements extension interfaces, PluginsService loads it
in an isolated classloader, the engine queries each interface at startup. k-NN is the
most instructive concrete example in the ecosystem, because it touches almost every
extension point at once and then adds something no pure-Java plugin has: a native build.
This chapter assumes that deep dive and the section overview; it does not
re-derive the plugin-loading machinery, it shows what k-NN plugs into it.
After this chapter you can:
- Name every extension interface
KNNPluginimplements and what each contributes. - Navigate the k-NN repo's module layout, including the
jni/native build and the custom codec. - Trace the index path (vector → graph in a segment file) and the query path
(
knnquery → native search → score) end to end. - Explain the native-memory subsystem (
NativeMemoryCacheManager, the circuit breaker) and the training/model system index, and point at the source for each.
Note: The term cluster manager (formerly master) is the node that owns cluster state. It matters here twice: training is coordinated through it, and the
.opensearch-knn-modelssystem index is ordinary cluster-managed index metadata, not shard-local state.
KNNPlugin: one class, eleven interfaces
A pure-Java plugin usually implements one or two extension interfaces. KNNPlugin
implements eleven. That is the whole architecture in a single declaration — every
subsystem k-NN adds is one interface on this class.
cd ~/src/oss-repos/k-NN
grep -n "public class KNNPlugin" src/main/java/org/opensearch/knn/plugin/KNNPlugin.java
# public class KNNPlugin extends Plugin implements
# MapperPlugin, SearchPlugin, ActionPlugin, EnginePlugin, ClusterPlugin,
# ScriptPlugin, ExtensiblePlugin, SystemIndexPlugin, ReloadablePlugin,
# SearchPipelinePlugin { ...
sed -n '179,200p' src/main/java/org/opensearch/knn/plugin/KNNPlugin.java
Warning: the exact interface list and line numbers drift between releases — k-NN targets a specific OpenSearch version (
opensearch_versioninbuild.gradle, e.g.3.6.0-SNAPSHOT). Alwaysgrepto confirm against your checkout rather than trusting a number printed here.
| Interface | k-NN's contribution | The KNNPlugin method | Subsystem chapter |
|---|---|---|---|
MapperPlugin | the knn_vector field type | getMappers() | field type (below), query path |
SearchPlugin | the knn query | getQueries() | query path |
ActionPlugin | REST handlers + transport actions (warmup, stats, train, clear-cache, model CRUD) | getRestHandlers(), getActions() | actions (below) |
EnginePlugin | a custom EngineFactory (so the engine knows about k-NN segments/merges) | getEngineFactory(IndexSettings) | codec + merge wiring |
ScriptPlugin | the exact knn_score scoring script engine | getScriptEngine(...) | warm-up scenario 5 |
SystemIndexPlugin | the .opensearch-knn-models model index descriptor | getSystemIndexDescriptors(...) | training (below) |
ClusterPlugin | cluster-lifecycle hooks (e.g. circuit-breaker / cache coordination) | cluster hooks | native memory (below) |
ExtensiblePlugin | SPI so other plugins can extend k-NN | (SPI loading) | plugin architecture |
ReloadablePlugin | secure-settings reload (e.g. remote-build credentials) | reload(Settings) | remote index build |
SearchPipelinePlugin | search-pipeline processors (e.g. normalization for hybrid search) | search-pipeline processors | hybrid/neural search |
The custom Lucene codec is not a separate extension interface — it is registered the
Lucene way, via Java's ServiceLoader/SPI (META-INF/services), and selected per-index
through the EnginePlugin's engine factory. More on that in the index-path section.
# Confirm each registration method exists.
grep -n "getMappers\|getQueries\|getRestHandlers\|getActions\|getEngineFactory\|getScriptEngine\|getSystemIndexDescriptors\|reload" \
src/main/java/org/opensearch/knn/plugin/KNNPlugin.java
The repo layout
The k-NN repository is unusual in the OpenSearch world because it has two trees that
must agree: a Java tree (src/main/java) and a native C++ tree (jni/). Knowing where
things live saves you the most time you'll lose as a new contributor.
k-NN/
├── build.gradle # opensearch_version; depends on PUBLISHED core artifacts
├── jni/ # the native build (CMake)
│ ├── CMakeLists.txt
│ ├── src/ # faiss_wrapper.cpp, nmslib_wrapper.cpp, org_opensearch_knn_jni_*.cpp
│ ├── include/ # JNI headers (generated from the Java native methods)
│ └── external/ # faiss + nmslib as GIT SUBMODULES
└── src/main/java/org/opensearch/knn/
├── plugin/
│ ├── KNNPlugin.java # the one class, eleven interfaces
│ ├── rest/ # RestKNNWarmupHandler, RestKNNStatsHandler, RestTrainModelHandler, ...
│ ├── transport/ # *TransportAction + *Request/*Response (warmup, stats, train, model CRUD)
│ ├── script/ # the knn_score script engine + KNNScoringUtil
│ └── stats/ # KNNStats and its suppliers
├── index/
│ ├── mapper/ # KNNVectorFieldMapper, KNNVectorFieldType
│ ├── query/ # KNNQueryBuilder, KNNQuery, KNNWeight, KNNScorer
│ ├── engine/ # KNNEngine, KNNMethod, MethodComponent; faiss/ lucene/ nmslib/
│ ├── codec/ # KNN1030Codec, NativeEngines990KnnVectorsFormat, backward_codecs/, nativeindex/
│ ├── memory/ # NativeMemoryCacheManager, NativeMemoryAllocation, load strategies
│ ├── SpaceType.java # l2 / cosinesimil / innerproduct / l1 / linf / hamming
│ ├── VectorDataType.java # FLOAT / BYTE / BINARY
│ ├── KNNSettings.java # knn.memory.circuit_breaker.*, ef_search, etc.
│ └── KNNCircuitBreaker.java # the native-memory guard
├── indices/ # ModelDao, Model, ModelState, training (.opensearch-knn-models)
└── jni/ # JNIService -> FaissService / NmslibService; JNICommons; PlatformUtils
# Walk it yourself.
ls src/main/java/org/opensearch/knn/
ls src/main/java/org/opensearch/knn/index/
ls jni/ # CMakeLists.txt, src/, include/, external/
ls jni/external/ # faiss nmslib (submodules — empty without --recursive!)
Warning:
build.gradledeclares a dependency on published OpenSearch artifacts (like every out-of-repo plugin — see in-repo vs out-of-repo), and the native build pulls faiss/nmslib as submodules underjni/external/. A fresh clone withoutgit submodule update --init --recursivebuilds the Java side fine and then fails the native step with cryptic CMake errors. This is the #1 "k-NN won't build" cause.
The native build (jni/) and the custom codec
Two structural facts make k-NN unlike any pure-Java plugin, and they're the two things worth understanding before you read any Java.
First, the JNI native build. Under jni/ is a CMake project that compiles C++ glue
(faiss_wrapper.cpp, nmslib_wrapper.cpp, and the generated org_opensearch_knn_jni_*
files) and links the faiss and nmslib libraries from jni/external/. The Java
side calls into it through JNIService, which dispatches to FaissService or
NmslibService. Those classes declare native methods whose implementations live in the
compiled .so/.dylib/.dll. JNICommons handles the off-heap memory bookkeeping;
PlatformUtils probes the CPU for AVX2/AVX-512 so faiss uses the fastest SIMD path
available — the same vectorization story as SIMD in Lucene,
just on the C++ side.
grep -rn "native " src/main/java/org/opensearch/knn/jni/FaissService.java | head
ls jni/src/ # the C++ that backs those native methods
grep -rn "isAVX512SupportedBySystem\|isAVX2SupportedBySystem" \
src/main/java/org/opensearch/knn/jni/PlatformUtils.java
Second, the custom Lucene codec. When you index into a faiss or nmslib field, the
HNSW/IVF graph is not stored the way a normal Lucene field is. k-NN supplies a custom
codec — the current one is KNN1030Codec (older
versions live under codec/backward_codecs/, e.g. KNN990Codec, for reading older
segments). Its KnnVectorsFormat is NativeEngines990KnnVectorsFormat, which, at
flush/merge time, calls JNI to build the native graph and writes it as extra segment
files beside Lucene's normal .cfs/.fdt/.tim/etc.
# The current codec and its vectors format.
ls src/main/java/org/opensearch/knn/index/codec/KNN1030Codec/
find src/main/java -name "NativeEngines990KnnVectorsFormat.java"
# Older codecs kept only for reading older segments (backward compatibility).
ls src/main/java/org/opensearch/knn/index/codec/backward_codecs/
The lucene engine is the exception: it does not use k-NN's native codec at all. It
writes vectors with Lucene's own KnnFloatVectorField and HNSW format (.vec/.vex/
.vem), exactly as described in HNSW in Lucene. This
is the single deepest architectural fork inside k-NN, and the next chapter
(engines) is built around it: faiss/nmslib write native graphs
through k-NN's codec into off-heap memory; lucene writes Lucene graphs through Lucene's
codec onto the JVM heap. Keep that sentence; it explains most of k-NN's behavior
differences.
flowchart TD
M["knn_vector mapping: engine = ?"] -->|faiss / nmslib| NC["KNN1030Codec<br/>NativeEngines990KnnVectorsFormat"]
M -->|lucene| LC["Lucene's KnnVectorsFormat<br/>Lucene99HnswVectorsFormat"]
NC -->|JNI build| NG["native graph segment files<br/>(loaded OFF-HEAP via NativeMemoryCacheManager)"]
LC -->|Lucene write| LG[".vec / .vex / .vem<br/>(Lucene's own format, on JVM heap)"]
The index path: a vector becomes a graph in a segment
Follow one float array from curl to disk. This is the same general write path as
core indexing — IndexShard → InternalEngine →
Lucene IndexWriter — with k-NN inserting itself at the field-mapping and codec layers.
flowchart TD
R["POST /index/_doc { embedding: [..] }"] --> SHARD["IndexShard.applyIndexOperationOnPrimary"]
SHARD --> ENG["InternalEngine.index -> Lucene IndexWriter.addDocument"]
ENG --> FT["KNNVectorFieldMapper.parseCreateField<br/>validates dim/space/method, stores the float[]"]
FT --> FLUSH["flush / merge"]
FLUSH --> CODEC{"engine?"}
CODEC -->|faiss/nmslib| NF["NativeEngines990KnnVectorsFormat<br/>-> JNIService -> FaissService (build HNSW/IVF)"]
CODEC -->|lucene| LF["Lucene's HNSW format builds the graph"]
NF --> SEG["native graph written as segment files"]
LF --> SEG2[".vec/.vex/.vem written by Lucene"]
KNNVectorFieldMapper.Builderparses the mapping once:dimension,space_type,data_type, and themethod(ormodel_id). It validates them against the chosenKNNEngine's declared capabilities (you cannot, e.g., ask nmslib for a filter or IVF for the lucene engine — those checks happen here). It produces aKNNVectorFieldType.- On each document,
KNNVectorFieldMapper.parseCreateFieldreads thefloat[]/byte[], checks the dimension, and stores it. - The graph isn't built per-document — it's built at flush (segment creation) and
rebuilt at merge, because Lucene segments are immutable
(refresh/flush/merge). The codec's
KnnVectorsWritergathers all the vectors in the flushing segment and calls JNI once to build the whole graph, which is far cheaper than incremental inserts. - The result is written as segment files. For faiss/nmslib these are k-NN's own files;
for lucene they're Lucene's
.vec/.vex/.vem.
# The field mapper that validates and stores.
grep -rn "parseCreateField\|class Builder\|class KNNVectorFieldMapper" \
src/main/java/org/opensearch/knn/index/mapper/KNNVectorFieldMapper.java | head
# The codec's writer that calls JNI at flush/merge to build the graph.
grep -rn "KnnVectorsWriter\|flush\|mergeOneField\|JNIService" \
src/main/java/org/opensearch/knn/index/codec/ | head
The query path: knn query → native search → score
The mirror image: a query vector goes down to faiss and the nearest k come back. This
is a SearchPlugin contribution that slots into the normal
search execution fan-out — the coordinating node
fans out to shards, each shard runs the query phase, results merge.
flowchart TD
Q["knn query in _search body"] --> QB["KNNQueryBuilder.doToQuery"]
QB --> KQ["KNNQuery (per-shard Lucene Query)"]
KQ --> W["KNNWeight.scorer per segment"]
W --> CB{"graph loaded off-heap?"}
CB -->|miss| LOAD["NativeMemoryCacheManager loads via JNIService -> FaissService"]
CB -->|hit| SRCH
LOAD --> SRCH["JNI: faiss searches the graph, returns docIds + distances"]
SRCH --> SC["KNNScorer: distance -> score (SpaceType.scoreTranslation)"]
SC --> COLL["Lucene collector: top-k per shard"]
COLL --> RED["coordinating node merges per-shard top-k"]
KNNQueryBuilder.doToQueryvalidates the query against the field's mapping (dimension must match, filter only if the engine supports it, radial only if supported) and builds aKNNQuery.- Per segment,
KNNWeightobtains the loaded native graph fromNativeMemoryCacheManager(loading it on a cache miss), applies anyfilteras aBitSetthe engine traverses against, and calls JNI to retrieve the nearestkdoc IDs with their distances. KNNScorerconverts each engine distance into a Lucene score via theSpaceType's score translation (so nearer = higher, composable with normal scoring).- The per-shard top-
kmerge on the coordinating node exactly like any search. The full mechanics — filtering, exact rescoring, thekvssizedistinction — are the subject of the k-NN query path chapter.
grep -rn "doToQuery\|class KNNQuery\b\|class KNNWeight\|class KNNScorer" \
src/main/java/org/opensearch/knn/index/query/ | head
Native memory and the circuit breaker
This is the subsystem that makes k-NN operationally different from every other plugin, and the one most likely to be the subject of an issue you pick up. faiss/nmslib graphs live outside the JVM heap, in native memory, loaded on first query (or via the warmup API).
| Concept | Class | Job |
|---|---|---|
| The cache of loaded graphs | NativeMemoryCacheManager | guava-cache keyed by segment graph file; holds NativeMemoryAllocations |
| A loaded graph allocation | NativeMemoryAllocation | a native pointer + its size, with eviction/close semantics |
| Loading a graph | NativeMemoryLoadStrategy → JNIService | reads/mmaps the graph file into native memory |
| The off-heap cap | KNNCircuitBreaker | trips when loading another graph would exceed the limit |
| The settings | KNNSettings | knn.memory.circuit_breaker.enabled, knn.memory.circuit_breaker.limit |
find src/main/java -name "NativeMemoryCacheManager.java" -o -name "KNNCircuitBreaker.java"
grep -n "KNN_MEMORY_CIRCUIT_BREAKER_ENABLED\|KNN_MEMORY_CIRCUIT_BREAKER_CLUSTER_LIMIT" \
src/main/java/org/opensearch/knn/index/KNNSettings.java
The circuit breaker is deliberately separate from core OpenSearch's circuit breakers, which guard heap. k-NN's memory is off-heap, invisible to those breakers and to a JVM heap dump — so k-NN needs its own accounting. The design of this guard is actively debated: Investigate rearchitecture of the native memory circuit breaker — k-NN #1582. Read that issue: it's a concentrated dose of the hard questions (per-node vs per-index limits, eviction policy, the interaction with the OS page cache) that off-heap memory management raises.
Warning: because graph memory is off-heap, a leak here does not show up in a heap dump and does not trip the core circuit breakers. You diagnose it through
GET /_plugins/_knn/stats(graph_memory_usage, evictions, breaker state) and OS-level RSS, not the JVM tools you'd reach for with any other plugin. See native integration and memory.
Training and the model system index
HNSW is train-free, but IVF and PQ need training (their centroids and codebooks are learned from a data sample — see algorithms). k-NN handles this with a dedicated workflow backed by a system index.
# The training action and the model data-access layer.
find src/main/java -name "TrainingModelTransportAction.java" -o -name "TrainingModelRequest.java"
grep -n "MODEL_INDEX_NAME\|class ModelDao\|class Model\b\|enum ModelState" \
src/main/java/org/opensearch/knn/indices/ModelDao.java \
src/main/java/org/opensearch/knn/common/KNNConstants.java
# MODEL_INDEX_NAME = ".opensearch-knn-models"
POST /_plugins/_knn/models/<id>/_trainhitsRestTrainModelHandler→TrainingModelTransportAction, which runs k-means (IVF centroids / PQ codebooks) over a training sample and writes a serialized model.- The model is stored as a document in the
.opensearch-knn-modelssystem index — an ordinary replicated OpenSearch index that k-NN owns and declares viagetSystemIndexDescriptors()(SystemIndexPlugin). It is cluster-managed metadata, not shard-local: the cluster manager coordinates its creation and the model's lifecycle state (training→created, orfailed), tracked throughModelDao/ModelState. - A field then references the trained model by
model_idinstead of an inlinemethod.
This is why KNNPlugin is a SystemIndexPlugin: the model store must be a protected
index the engine knows not to let users mutate directly. The full workflow, with the
sequence diagram, is in algorithms.
The full wiring diagram
Putting it together — KNNPlugin's interfaces on the left, the subsystems they register
on the right, and the two data paths that flow through them.
flowchart LR
subgraph plugin["KNNPlugin (one class)"]
MP["MapperPlugin"] --> FM["KNNVectorFieldMapper / KNNVectorFieldType"]
SP["SearchPlugin"] --> QP["KNNQueryBuilder -> KNNQuery -> KNNWeight/KNNScorer"]
AP["ActionPlugin"] --> RH["Rest*Handler + *TransportAction<br/>warmup, stats, train, clear-cache"]
EP["EnginePlugin"] --> EF["EngineFactory + custom codec wiring"]
SCP["ScriptPlugin"] --> SE["knn_score script engine"]
SIP["SystemIndexPlugin"] --> SI[".opensearch-knn-models (ModelDao)"]
end
FM -->|index path| CODEC["KNN1030Codec / NativeEngines990KnnVectorsFormat"]
QP -->|query path| MEM["NativeMemoryCacheManager + KNNCircuitBreaker"]
CODEC <-->|JNI| JNI["JNIService -> FaissService / NmslibService"]
MEM <-->|JNI| JNI
JNI <-->|links| NATIVE["faiss / nmslib (C++ under jni/)"]
EF --> CODEC
SI -.model_id.-> FM
Every box is a grep away. The point of this chapter is that there are no hidden pieces:
eleven interfaces, a field mapper, a query, a codec, a native cache, a JNI bridge, and a
model index — and the two arrows (index path, query path) that thread through them.
Common bugs and symptoms
| Symptom | Likely cause | Where to look |
|---|---|---|
./gradlew run fails on a C++/CMake step | submodules not initialized | git submodule update --init --recursive; jni/external/ |
| Native graph not built; queries do exact scan | field mapped without a method (pure stored field) | KNNVectorFieldMapper mapping; add a method |
circuit_breaker is triggered on queries | off-heap limit hit loading graphs | KNNCircuitBreaker, knn.memory.circuit_breaker.limit; #1582 |
| RSS grows but heap dump looks fine | off-heap graph memory (invisible to heap tools) | _plugins/_knn/stats graph memory; NativeMemoryCacheManager |
model_id field rejects docs | model state training/failed, not created | ModelDao, GET _plugins/_knn/models/<id>, .opensearch-knn-models |
| Filter/radial/IVF mapping rejected on an engine | engine capability check (e.g. nmslib has no filter) | KNNEngine capability sets; KNNQueryBuilder/mapper validation |
| Old segments unreadable after upgrade | backward codec missing for that segment version | codec/backward_codecs/; check the codec version chain |
Plugin loads but knn query is unknown [knn] | wrong plugin version for the node | opensearch.version in descriptor; rebuild — see plugin arch |
Validation: prove you understand this
- List the extension interfaces
KNNPluginimplements and name exactly one thing each contributes. Which one registers the field type, which the query, which the model system index, and which the exact-scoring script? - Explain why the custom Lucene codec is not registered as an extension interface, and how it is selected for a faiss field but bypassed for a lucene field.
- Trace the index path for one
float[]fromIndexShardto a segment file, naming the field mapper, the codec/vectors-format, and the JNI call — and say when the graph is actually built (and why not per-document). - Trace the query path for one
knnquery, namingKNNQueryBuilder,KNNQuery,KNNWeight/KNNScorer, the native-memory load, and the distance→score step. - Explain why k-NN needs its own circuit breaker instead of using core OpenSearch's, and how you would diagnose an off-heap memory problem (which tools, which stats). Cite the rearchitecture issue.
- Describe the training/model flow: what runs k-means, where the model is stored, what makes that index "system," which node coordinates it, and how a field references the result.
When you can do all six, read Engines to see how the faiss/lucene/nmslib
fork plays out in capabilities and segment files, then the query path
and native integration and memory for the two data paths in
full depth. For the algorithms behind method, see
algorithms; for the contribution landscape and live RFCs,
real issues and RFCs.