AnswersScale & Architecture

How Do You Run kNN Search Through the Elasticsearch API?

How do you map a dense_vector field, write a knn clause, read the scores it returns, and make sure a filter narrows the candidates before ranking rather than after?

4 min readAugust 2026

The short answer

You map a field as dense_vector with dims and a similarity, index documents with a vector in that field, and send a knn clause with field, query_vector, and k. The response is an ordinary hits list, and the score is Elasticsearch's normalized similarity, so a cosine hit comes back as (1 + cosine) / 2.

Put any filter inside the knn clause rather than in post_filter, so it narrows the candidates before ranking and still returns k matches. TopK serves the same request shape through its Elasticsearch-compatible API, and its engine gets faster as the filter gets tighter.

How do you map a vector field?

A vector field is declared in the index mapping with a type of dense_vector, the number of dimensions, and the similarity the engine should rank by. Elasticsearch offers cosine, dot_product, l2_norm, and max_inner_product, and it defaults to cosine for float vectors (Elastic field reference). TopK's Elasticsearch API accepts the first three. The dot_product option requires every vector, including the query vector, to be unit length, so use cosine unless you normalize your embeddings yourself.

PUT /books
{
"mappings": {
"properties": {
"genre": { "type": "keyword" },
"embedding": { "type": "dense_vector", "dims": 768, "similarity": "cosine" }
}
}
}

Elasticsearch lets you omit dims and infers it from the first vector you index. TopK requires dims in the mapping and returns a 400 at index creation without it, as it does for a bit element type whose dims is not a multiple of 8, so a mistake in the mapping never reaches the data.

What does the knn clause look like?

The knn clause names the field, carries the query vector, and asks for k nearest neighbors. It sits at the top level of the search body, next to size and sort, and the response is the same hits structure every other query returns.

POST /books/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.12, -0.03, ...],
"k": 10
},
"size": 10
}

The vector has to match the mapped dimensions, and it has to contain finite numbers. A k of zero, a num_candidates smaller than k, and a knn against a field that is not a vector all return a 400 rather than an empty result.

The filter runs before ranking, so k matches come back when k exist.

knn clause

field, query_vector, k, filter

Filter

narrows candidates first

Similarity

cosine, dot_product, or l2_norm

Top k hits

scores normalized to 0 to 1

What do the scores mean?

The _score on a kNN hit is a normalized similarity, and the normalization depends on the metric. For cosine and for float dot_product, Elasticsearch reports (1 + similarity) / 2, so an identical vector scores 1.0 and an orthogonal vector scores 0.5 (Elastic field reference). For l2_norm, the score falls from 1.0 as the distance grows and stays positive. Read the score as a ranking value, and if you threshold on it, threshold on the normalized number rather than on a raw cosine.

TopK's Elasticsearch API returns the same numbers. An identical cosine vector scores 1.0, an orthogonal one scores 0.5, and max_score equals the top hit, so an application that reads _score gets what it read before.

Does the filter run before or after ranking?

A filter inside the knn clause runs before ranking. Elasticsearch applies it during the approximate search so that k matching documents come back, while a post_filter applies after the kNN step and can return fewer than k (Elastic kNN guide). If you filter by tenant, category, or permission, the filter belongs inside knn, or a small tenant sees a short or empty result list even when it has plenty of matching documents.

{
"knn": {
"field": "embedding",
"query_vector": [0.12, -0.03, ...],
"k": 10,
"filter": { "term": { "genre": "fantasy" } }
}
}

The inner filter is necessary for the same reason that filtering breaks HNSW. A graph index organizes vectors by neighborhood, and the documents that satisfy a selective filter are scattered across it, so an engine that filters after traversal runs out of candidates. TopK's engine treats the filter as part of the query plan rather than as a mask over the index, and its stated design goal is that highly selective queries get faster, because the filter shrinks the set the engine scores. Through the Elasticsearch-compatible API, the knn.filter restricts candidates before ranking and hits.total reports the number that matched.

What does this look like on TopK?

The request is the one above, sent by the Elasticsearch client you already use, with the endpoint and the API key pointed at TopK.

from elasticsearch import Elasticsearch
es = Elasticsearch("https://<your-topk-es-endpoint>", api_key="<topk-api-key>")
res = es.search(index="books", knn={
"field": "embedding",
"query_vector": query_embedding,
"k": 10,
"filter": {"term": {"genre": "fantasy"}},
}, size=10)
for hit in res["hits"]["hits"]:
print(hit["_id"], hit["_score"])

On TopK, a dense_vector field is served by a vector index with the metric you named, and Elastic's rank_vectors type is served by a multi-vector index that a matrix query_vector searches by MaxSim. The compatibility overview covers what TopK accepts and rejects, and the hybrid guide covers combining knn with a keyword or semantic query.