Three engines behind one knn_vector field
A knn_vector field with index.knn: true builds an approximate-nearest-neighbour structure that stands in for exact k-NN — scoring one query against all N vectors of dimension d is O(N * d), billions of multiply-adds and tens of gigabytes at scale, which approximation dodges by touching a tiny fraction of the points. The distinguishing fact about OpenSearch is that knn_vector is a front for three interchangeable backends, chosen per field under method.engine:
| Engine | Methods | Character |
|---|---|---|
lucene | HNSW | Pure-Java, no native memory, tight OpenSearch integration, filtering built in |
faiss | HNSW, IVF | Native library, richest quantization (SQ, PQ), disk mode, IVF for huge sets |
nmslib | HNSW | The original engine, now deprecated — migrate to Faiss or Lucene |
The split that matters: Lucene stores its graphs the way Lucene stores everything, so they ride the filesystem cache. Faiss and nmslib are native libraries whose graphs live in off-heap native memory that OpenSearch loads and budgets separately. Choosing an engine is choosing a memory model and a feature set, not just an implementation.
Space types and how a distance becomes a score
The space_type fixes the similarity metric, and OpenSearch always reports a bounded, larger-is-better score, so each raw distance is passed through a monotone conversion:
l2 : d = Σ(a_j - b_j)^2 score = 1 / (1 + d)
cosinesimil: c = (a·b)/(|a||b|) score = (1 + c) / 2
innerproduct: ip = a·b score = ip + 1 (ip ≥ 0)
score = 1 / (1 - ip) (ip < 0)
l1 / linf : Manhattan / Chebyshev score = 1 / (1 + d)Two consequences follow. Cosine similarity is just inner product on normalized vectors, so if you L2-normalize embeddings at index and query time you can use the cheaper innerproduct space and skip the per-comparison norm. And the conversion is monotone, so it never reorders results — it only maps distances into a comparable band you can threshold or blend with a BM25 score.
HNSW parameters: m, ef_construction, ef_search
All three engines can build Hierarchical Navigable Small World graphs — a layered proximity graph whose sparse upper layers are long-range sketches and whose layer 0 holds every node, giving a roughly log_m(N) greedy descent instead of an O(N) scan. Three method parameters tune it:
m (def 16) neighbours/node → permanent graph size + recall
ef_construction (build) insert-time beam → graph quality, one-off cost
ef_search (query) search-time beam → recall vs latency, live dialThe asymmetry is the tuning story: only ef_search is cheap to change after indexing, so it is the knob you turn on a slow query. m and ef_construction are baked in at build and need a reindex to change.
Faiss IVF: coarse quantization instead of a graph
Faiss adds a second method HNSW cannot match at very large N: an inverted file (ivf). Rather than a proximity graph, IVF runs k-means to learn nlist centroids, assigns every vector to its nearest centroid, and at query time probes only the nprobe closest lists:
train : k-means → nlist centroids (needs a training sample)
search: rank centroids by distance, scan the nprobe nearest lists
cost ≈ O(nlist * d) + O((nprobe / nlist) * N * d)IVF trades a mandatory training step and a recall knob (nprobe) for a far smaller memory footprint: it stores no per-node edge lists, only centroids plus the vectors. HNSW usually wins on recall-at-latency for moderate corpora; IVF wins when N reaches the hundreds of millions and the graph’s edge memory no longer fits in RAM.
Exact search with the knn_score script
Approximate search is the wrong tool when the candidate set is already tiny or when you need a provably correct top-k. For that OpenSearch exposes a brute-force path: a script_score query using the Painless knn_score function, which scores every document that passes the surrounding filter against the query vector at full precision.
script_score { filter q } → for each surviving doc: sim(query, doc.vector)
cost = O(M * d) where M = docs passing the filter, not NThis needs no index.knn or built graph at all — the vectors can be stored plain. It is exact and the right answer whenever a selective filter leaves only a few thousand candidates: scanning M vectors directly beats walking a graph built for all N. It is the wrong answer over millions of unfiltered documents, where its linear cost is exactly what HNSW exists to avoid.
Filtering: the pre-filter that traverses the graph
Real queries want nearest neighbours among documents that also match a predicate — a tenant id, a price band, a date range. Post-filtering (run k-NN, then drop non-matches) can return too few results. OpenSearch’s efficient filtering instead pushes the predicate into the traversal — the graph walk only accepts nodes in the matching bit set.
The important caveat is engine-specific: efficient filtering is supported by the Lucene and Faiss engines, not by deprecated nmslib. And a graph built over all N points has edges chosen for the unfiltered space, so a very selective filter fragments the reachable subgraph. OpenSearch handles that the way you would hope: when the filtered set is small relative to the approximate work, it falls back to an exact scan over just the matching documents — faster and exactly correct on a tiny survivor set.
Faiss quantization: fp16, scalar, and product
The vectors, not the graph, dominate memory, so Faiss’s encoders are the highest-leverage knob OpenSearch gives you. Each maps float32 components onto something smaller and stores the reduced form in the index:
fp16 (SQ) : clip to [-65504, 65504], store 16-bit half ~2x smaller
int8 (SQ) : q_j = round((v_j - lo) / (hi - lo) * 255) ~4x smaller
PQ : split d into m subvectors, k-means codebook up to ~64x smaller
each subvector → one byte code (nbits = 8)Scalar quantization (fp16, int8) is nearly lossless and needs no training. Product quantization is far more aggressive — it partitions each vector into subvectors and replaces each with its nearest codebook entry, so distances come from small lookup tables — but it requires training and trades measurable recall. The discipline is the usual one: compress until the working set fits in memory, then stop.