kNN / HNSW vector search

This section documents the current Apache Lucene 10.x line — Lucene 10 requires Java 21 — as published at the Apache Lucene documentation and Javadoc, which is the reference these pages are written and verified against. No specific patch version is pinned; examples target lucene-core 10.x and the companion modules. Some areas (the Panama foreign-memory / Vector API internals, codec file-format internals, and the nightly benchmark harness) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Lucene iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Lucene stores a dense embedding in a vector field and searches it approximately with an HNSW graph: a layered proximity graph that a query walks greedily from a sparse top layer down to the full bottom layer. This is the engine under Elasticsearch’s dense_vector and Solr’s DenseVectorField; this page is the core-Lucene API.

Vector fields

KnnFloatVectorField holds a float[]; KnnByteVectorField holds a byte[] (one byte per dimension, roughly a quarter of the size, at some recall cost). Every value in a field must have the same dimension count, and every value is compared with the same VectorSimilarityFunction, fixed when the field is created.

// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/document/KnnFloatVectorField.html
import org.apache.lucene.document.KnnByteVectorField;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.index.VectorSimilarityFunction;

float[] embedding = model.embed("wireless noise-cancelling headphones"); // length == dims
Document doc = new Document();
doc.add(new KnnFloatVectorField("vec", embedding, VectorSimilarityFunction.COSINE));

byte[] q8 = quantiseToBytes(embedding);
doc.add(new KnnByteVectorField("vec_b", q8, VectorSimilarityFunction.DOT_PRODUCT));

writer.addDocument(doc);
VectorSimilarityFunction Use when

EUCLIDEAN

Straight L2 distance; the default, always safe

DOT_PRODUCT

Vectors are unit length — cheapest to compute

COSINE

Angle only; Lucene normalises internally, so magnitude is ignored

MAXIMUM_INNER_PRODUCT

Un-normalised vectors where magnitude carries meaning

Scores are always returned as a positive, higher-is-better number regardless of the function. Field reference: KnnFloatVectorField.

The HNSW graph and its codec formats

Graph parameters live in the KnnVectorsFormat on the codec, not on the field. Lucene99HnswVectorsFormat takes maxConn (M — edges kept per node, default 16) and beamWidth (candidate list size while inserting a node, default 100). Higher values raise recall and index size and slow the build; neither affects query latency directly.

// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.html
import org.apache.lucene.codecs.KnnVectorsFormat;
import org.apache.lucene.codecs.lucene99.Lucene99HnswScalarQuantizedVectorsFormat;
import org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsFormat;
import org.apache.lucene.codecs.lucene100.Lucene100Codec;

KnnVectorsFormat plain     = new Lucene99HnswVectorsFormat(16, 100);   // full float32
KnnVectorsFormat quantised = new Lucene99HnswScalarQuantizedVectorsFormat(); // int8/int7 on disk

IndexWriterConfig iwc = new IndexWriterConfig(analyzer);
iwc.setCodec(new Lucene100Codec() {
    @Override
    public KnnVectorsFormat getKnnVectorsFormatForField(String field) {
        return field.equals("vec") ? quantised : super.getKnnVectorsFormatForField(field);
    }
});

Lucene99HnswScalarQuantizedVectorsFormat keeps the float graph in memory but stores vectors scalar-quantised (7- or 4-bit) on disk; later 10.x lines add int4 and binary (BBQ) quantised formats in the same codecs.lucene99 / newer codec packages, trading a little recall for a large drop in disk and memory. Format list: codecs.lucene99 package.

Querying

KnnFloatVectorQuery (and KnnByteVectorQuery) does approximate search: it returns the k nearest vectors to a target. In core Lucene k is also the per-segment beam size — engines layered on top expose that second number separately as num_candidates / topK. A filter Query restricts which documents the graph walk may return, so k hits still come back even under a selective filter (pre-filtering, not post-filtering).

// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/KnnFloatVectorQuery.html
import org.apache.lucene.search.KnnFloatVectorQuery;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.index.Term;

float[] qv = model.embed("headphones that block background noise");

Query knn = new KnnFloatVectorQuery("vec", qv, 10);

Query filter = new TermQuery(new Term("category", "audio"));
Query filteredKnn = new KnnFloatVectorQuery("vec", qv, 10, filter);   // pre-filtered

TopDocs hits = searcher.search(filteredKnn, 10);

Exact kNN

When the candidate set is already small, skip the graph and score every match by brute force with a DoubleValuesSource over the vector field, wrapped in a FunctionScoreQuery:

// DoubleValuesSource.similarityToQueryVector() is a per-leaf helper -- wrap it in a
// DoubleValuesSource so it can be combined with a FunctionScoreQuery:
// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/DoubleValuesSource.html
import org.apache.lucene.queries.function.FunctionScoreQuery;
import org.apache.lucene.search.*;
import org.apache.lucene.index.LeafReaderContext;
import java.io.IOException;

DoubleValuesSource exact = new DoubleValuesSource() {
    @Override
    public DoubleValues getValues(LeafReaderContext ctx, DoubleValues scores) throws IOException {
        return DoubleValuesSource.similarityToQueryVector(ctx, qv, "vec");
    }
    @Override public boolean needsScores() { return false; }
    @Override public DoubleValuesSource rewrite(IndexSearcher reader) { return this; }
    @Override public boolean equals(Object o) { return o != null && getClass() == o.getClass(); }
    @Override public int hashCode() { return getClass().hashCode(); }
    @Override public String toString() { return "exactVectorSimilarity(vec)"; }
};

Query exactKnn = new FunctionScoreQuery(
        new TermQuery(new Term("category", "audio")), exact);   // exact over the filtered set

Combine a text query and a vector query as SHOULD clauses of a BooleanQuery so both contribute to the score, or run the vector comparison only as a second pass with QueryRescorer.

import org.apache.lucene.search.*;

// (a) one ranking, both signals
Query hybrid = new BooleanQuery.Builder()
        .add(textQuery, BooleanClause.Occur.SHOULD)
        .add(new KnnFloatVectorQuery("vec", qv, 50), BooleanClause.Occur.SHOULD)
        .build();

// (b) lexical first pass, vector rescoring of the top N
// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/QueryRescorer.html
TopDocs firstPass = searcher.search(textQuery, 500);
Query vectorScore = new FunctionScoreQuery(new MatchAllDocsQuery(), exact);
Rescorer rescorer = new QueryRescorer(vectorScore) {
    @Override
    protected float combine(float first, boolean secondMatch, float second) {
        return 0.3f * first + 0.7f * second;
    }
};
TopDocs reranked = rescorer.rescore(searcher, firstPass, 10);

Because a BM25 score and a similarity score are not on one scale, an unweighted SHOULD blend favours whichever side produces larger numbers — weight the clauses or use the rescoring form when the lexical signal should decide the candidate set.

Panama Vector API acceleration

Lucene 10 ships vectorised distance kernels that use the JDK’s incubating Vector API (jdk.incubator.vector). On a supported JDK the accelerated implementation is selected automatically from the multi-release lucene-core JAR once the module is added on the launch command:

java --add-modules jdk.incubator.vector -cp app.jar:lucene-core-10.0.0.jar com.example.App
# Lucene logs a line confirming the Panama-optimised vector implementation is in use.

The foreign-memory and Vector API internals are linked, not covered in depth, from the org.apache.lucene.search package overview, which also summarises the vector-search API.

The HNSW search, visually

Layered HNSW graph with a greedy top-down search path ending at the nearest neighbour of the query vector