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:

EngineMethodsCharacter
luceneHNSWPure-Java, no native memory, tight OpenSearch integration, filtering built in
faissHNSW, IVFNative library, richest quantization (SQ, PQ), disk mode, IVF for huge sets
nmslibHNSWThe 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.

Advertisement

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.

Advertisement

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 dial

The 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 N

This 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.