Skip to content
5 changes: 5 additions & 0 deletions docs/changelog/126876.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 126876
summary: Improve HNSW filtered search speed through new heuristic
area: Vector Search
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ $$$index-final-pipeline$$$
$$$index-hidden$$$ `index.hidden`
: Indicates whether the index should be hidden by default. Hidden indices are not returned by default when using a wildcard expression. This behavior is controlled per request through the use of the `expand_wildcards` parameter. Possible values are `true` and `false` (default).

$$$index-dense-vector-hnsw-filter-heuristic$$$ `index.dense_vector.hnsw_filter_heuristic`
: The heuristic to utilize when executing a filtered search against vectors in an HNSW graph. This setting is in technical preview may be changed or removed in a future release. It can be set to:

* `acorn` (default) - Only vectors that match the filter criteria are searched. This is the fastest option, and generally provides faster searches at similar recall to `fanout`, but `num_candidates` might need to be increased for exceptionally high recall requirements.
* `fanout` - All vectors are compared with the query vector, but only those passing the criteria are added to the search results. Can be slower than `acorn`, but may yield higher recall.

$$$index-esql-stored-fields-sequential-proportion$$$

`index.esql.stored_fields_sequential_proportion`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import org.elasticsearch.index.mapper.IgnoredSourceFieldMapper;
import org.elasticsearch.index.mapper.InferenceMetadataFieldsMapper;
import org.elasticsearch.index.mapper.MapperService;
import org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper;
import org.elasticsearch.index.similarity.SimilarityService;
import org.elasticsearch.index.store.FsDirectoryFactory;
import org.elasticsearch.index.store.Store;
Expand Down Expand Up @@ -157,6 +158,7 @@ public final class IndexScopedSettings extends AbstractScopedSettings {
IndexSettings.INDEX_TRANSLOG_RETENTION_AGE_SETTING,
IndexSettings.INDEX_TRANSLOG_RETENTION_SIZE_SETTING,
IndexSettings.INDEX_SEARCH_IDLE_AFTER,
DenseVectorFieldMapper.HNSW_FILTER_HEURISTIC,
IndexFieldDataService.INDEX_FIELDDATA_CACHE_KEY,
IndexSettings.IGNORE_ABOVE_SETTING,
FieldMapper.IGNORE_MALFORMED_SETTING,
Expand Down
16 changes: 16 additions & 0 deletions server/src/main/java/org/elasticsearch/index/IndexSettings.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.elasticsearch.index.mapper.IgnoredSourceFieldMapper;
import org.elasticsearch.index.mapper.Mapper;
import org.elasticsearch.index.mapper.SourceFieldMapper;
import org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper;
import org.elasticsearch.index.translog.Translog;
import org.elasticsearch.indices.recovery.RecoverySettings;
import org.elasticsearch.ingest.IngestService;
Expand Down Expand Up @@ -896,6 +897,7 @@ private void setRetentionLeaseMillis(final TimeValue retentionLease) {
private volatile int maxTokenCount;
private volatile int maxNgramDiff;
private volatile int maxShingleDiff;
private volatile DenseVectorFieldMapper.FilterHeuristic hnswFilterHeuristic;
private volatile TimeValue searchIdleAfter;
private volatile int maxAnalyzedOffset;
private volatile boolean weightMatchesEnabled;
Expand Down Expand Up @@ -1091,6 +1093,7 @@ public IndexSettings(final IndexMetadata indexMetadata, final Settings nodeSetti
logsdbAddHostNameField = scopedSettings.get(LOGSDB_ADD_HOST_NAME_FIELD);
skipIgnoredSourceWrite = scopedSettings.get(IgnoredSourceFieldMapper.SKIP_IGNORED_SOURCE_WRITE_SETTING);
skipIgnoredSourceRead = scopedSettings.get(IgnoredSourceFieldMapper.SKIP_IGNORED_SOURCE_READ_SETTING);
hnswFilterHeuristic = scopedSettings.get(DenseVectorFieldMapper.HNSW_FILTER_HEURISTIC);
indexMappingSourceMode = scopedSettings.get(INDEX_MAPPER_SOURCE_MODE_SETTING);
recoverySourceEnabled = RecoverySettings.INDICES_RECOVERY_SOURCE_ENABLED_SETTING.get(nodeSettings);
recoverySourceSyntheticEnabled = DiscoveryNode.isStateless(nodeSettings) == false
Expand Down Expand Up @@ -1203,6 +1206,7 @@ public IndexSettings(final IndexMetadata indexMetadata, final Settings nodeSetti
this::setSkipIgnoredSourceWrite
);
scopedSettings.addSettingsUpdateConsumer(IgnoredSourceFieldMapper.SKIP_IGNORED_SOURCE_READ_SETTING, this::setSkipIgnoredSourceRead);
scopedSettings.addSettingsUpdateConsumer(DenseVectorFieldMapper.HNSW_FILTER_HEURISTIC, this::setHnswFilterHeuristic);
}

private void setSearchIdleAfter(TimeValue searchIdleAfter) {
Expand Down Expand Up @@ -1821,4 +1825,16 @@ public TimestampBounds getTimestampBounds() {
public IndexRouting getIndexRouting() {
return indexRouting;
}

/**
* The heuristic to utilize when executing filtered search on vectors indexed
* in HNSW format.
*/
public DenseVectorFieldMapper.FilterHeuristic getHnswFilterHeuristic() {
return this.hnswFilterHeuristic;
}

private void setHnswFilterHeuristic(DenseVectorFieldMapper.FilterHeuristic heuristic) {
this.hnswFilterHeuristic = heuristic;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ private static Version parseUnchecked(String version) {
public static final IndexVersion UPGRADE_TO_LUCENE_10_2_1 = def(9_023_00_0, Version.LUCENE_10_2_1);
public static final IndexVersion DEFAULT_OVERSAMPLE_VALUE_FOR_BBQ = def(9_024_0_00, Version.LUCENE_10_2_1);
public static final IndexVersion SEMANTIC_TEXT_DEFAULTS_TO_BBQ = def(9_025_0_00, Version.LUCENE_10_2_1);
public static final IndexVersion DEFAULT_TO_ACORN_HNSW_FILTER_HEURISTIC = def(9_026_0_00, Version.LUCENE_10_2_1);
/*
* STOP! READ THIS FIRST! No, really,
* ____ _____ ___ ____ _ ____ _____ _ ____ _____ _ _ ___ ____ _____ ___ ____ ____ _____ _
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
import org.apache.lucene.search.FieldExistsQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.join.BitSetProducer;
import org.apache.lucene.search.knn.KnnSearchStrategy;
import org.apache.lucene.util.BitUtil;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.VectorUtil;
import org.elasticsearch.common.ParsingException;
import org.elasticsearch.common.settings.Setting;
import org.elasticsearch.common.xcontent.support.XContentMapValues;
import org.elasticsearch.features.NodeFeature;
import org.elasticsearch.index.IndexVersion;
Expand Down Expand Up @@ -93,6 +95,7 @@
import java.util.function.Supplier;
import java.util.stream.Stream;

import static org.elasticsearch.cluster.metadata.IndexMetadata.SETTING_INDEX_VERSION_CREATED;
import static org.elasticsearch.common.Strings.format;
import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;

Expand All @@ -108,6 +111,51 @@ public static boolean isNotUnitVector(float magnitude) {
return Math.abs(magnitude - 1.0f) > EPS;
}

/**
* The heuristic to utilize when executing a filtered search against vectors indexed in an HNSW graph.
*/
public enum FilterHeuristic {
/**
* This heuristic searches the entire graph, doing vector comparisons in all immediate neighbors
* but only collects vectors that match the filtering criteria.
*/
FANOUT {
static final KnnSearchStrategy FANOUT_STRATEGY = new KnnSearchStrategy.Hnsw(0);

@Override
public KnnSearchStrategy getKnnSearchStrategy() {
return FANOUT_STRATEGY;
}
},
/**
* This heuristic will only compare vectors that match the filtering criteria.
*/
ACORN {
static final KnnSearchStrategy ACORN_STRATEGY = new KnnSearchStrategy.Hnsw(60);

@Override
public KnnSearchStrategy getKnnSearchStrategy() {
return ACORN_STRATEGY;
}
};

public abstract KnnSearchStrategy getKnnSearchStrategy();
}

public static final Setting<FilterHeuristic> HNSW_FILTER_HEURISTIC = Setting.enumSetting(FilterHeuristic.class, s -> {
IndexVersion version = SETTING_INDEX_VERSION_CREATED.get(s);
if (version.onOrAfter(IndexVersions.DEFAULT_TO_ACORN_HNSW_FILTER_HEURISTIC)) {
return FilterHeuristic.ACORN.toString();
}
return FilterHeuristic.FANOUT.toString();
},
"index.dense_vector.hnsw_filter_heuristic",
fh -> {},
Setting.Property.IndexScope,
Setting.Property.ServerlessPublic,
Setting.Property.Dynamic
);

private static boolean hasRescoreIndexVersion(IndexVersion version) {
return version.onOrAfter(IndexVersions.ADD_RESCORE_PARAMS_TO_QUANTIZED_VECTORS)
|| version.between(IndexVersions.ADD_RESCORE_PARAMS_TO_QUANTIZED_VECTORS_BACKPORT_8_X, IndexVersions.UPGRADE_TO_LUCENE_10_0_0);
Expand Down Expand Up @@ -2210,25 +2258,44 @@ public Query createKnnQuery(
Float oversample,
Query filter,
Float similarityThreshold,
BitSetProducer parentFilter
BitSetProducer parentFilter,
DenseVectorFieldMapper.FilterHeuristic heuristic
) {
if (isIndexed() == false) {
throw new IllegalArgumentException(
"to perform knn search on field [" + name() + "], its mapping must have [index] set to [true]"
);
}
KnnSearchStrategy knnSearchStrategy = heuristic.getKnnSearchStrategy();
return switch (getElementType()) {
case BYTE -> createKnnByteQuery(queryVector.asByteVector(), k, numCands, filter, similarityThreshold, parentFilter);
case BYTE -> createKnnByteQuery(
queryVector.asByteVector(),
k,
numCands,
filter,
similarityThreshold,
parentFilter,
knnSearchStrategy
);
case FLOAT -> createKnnFloatQuery(
queryVector.asFloatVector(),
k,
numCands,
oversample,
filter,
similarityThreshold,
parentFilter
parentFilter,
knnSearchStrategy
);
case BIT -> createKnnBitQuery(
queryVector.asByteVector(),
k,
numCands,
filter,
similarityThreshold,
parentFilter,
knnSearchStrategy
);
case BIT -> createKnnBitQuery(queryVector.asByteVector(), k, numCands, filter, similarityThreshold, parentFilter);
};
}

Expand All @@ -2246,12 +2313,13 @@ private Query createKnnBitQuery(
int numCands,
Query filter,
Float similarityThreshold,
BitSetProducer parentFilter
BitSetProducer parentFilter,
KnnSearchStrategy searchStrategy
) {
elementType.checkDimensions(dims, queryVector.length);
Query knnQuery = parentFilter != null
? new ESDiversifyingChildrenByteKnnVectorQuery(name(), queryVector, filter, k, numCands, parentFilter)
: new ESKnnByteVectorQuery(name(), queryVector, k, numCands, filter);
? new ESDiversifyingChildrenByteKnnVectorQuery(name(), queryVector, filter, k, numCands, parentFilter, searchStrategy)
: new ESKnnByteVectorQuery(name(), queryVector, k, numCands, filter, searchStrategy);
if (similarityThreshold != null) {
knnQuery = new VectorSimilarityQuery(
knnQuery,
Expand All @@ -2268,7 +2336,8 @@ private Query createKnnByteQuery(
int numCands,
Query filter,
Float similarityThreshold,
BitSetProducer parentFilter
BitSetProducer parentFilter,
KnnSearchStrategy searchStrategy
) {
elementType.checkDimensions(dims, queryVector.length);

Expand All @@ -2277,8 +2346,8 @@ private Query createKnnByteQuery(
elementType.checkVectorMagnitude(similarity, ElementType.errorByteElementsAppender(queryVector), squaredMagnitude);
}
Query knnQuery = parentFilter != null
? new ESDiversifyingChildrenByteKnnVectorQuery(name(), queryVector, filter, k, numCands, parentFilter)
: new ESKnnByteVectorQuery(name(), queryVector, k, numCands, filter);
? new ESDiversifyingChildrenByteKnnVectorQuery(name(), queryVector, filter, k, numCands, parentFilter, searchStrategy)
: new ESKnnByteVectorQuery(name(), queryVector, k, numCands, filter, searchStrategy);
if (similarityThreshold != null) {
knnQuery = new VectorSimilarityQuery(
knnQuery,
Expand All @@ -2296,7 +2365,8 @@ private Query createKnnFloatQuery(
Float queryOversample,
Query filter,
Float similarityThreshold,
BitSetProducer parentFilter
BitSetProducer parentFilter,
KnnSearchStrategy knnSearchStrategy
) {
elementType.checkDimensions(dims, queryVector.length);
elementType.checkVectorBounds(queryVector);
Expand Down Expand Up @@ -2330,8 +2400,16 @@ && isNotUnitVector(squaredMagnitude)) {
numCands = Math.max(adjustedK, numCands);
}
Query knnQuery = parentFilter != null
? new ESDiversifyingChildrenFloatKnnVectorQuery(name(), queryVector, filter, adjustedK, numCands, parentFilter)
: new ESKnnFloatVectorQuery(name(), queryVector, adjustedK, numCands, filter);
? new ESDiversifyingChildrenFloatKnnVectorQuery(
name(),
queryVector,
filter,
adjustedK,
numCands,
parentFilter,
knnSearchStrategy
)
: new ESKnnFloatVectorQuery(name(), queryVector, adjustedK, numCands, filter, knnSearchStrategy);
if (rescore) {
knnQuery = new RescoreKnnVectorQuery(
name(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.join.BitSetProducer;
import org.apache.lucene.search.join.DiversifyingChildrenByteKnnVectorQuery;
import org.apache.lucene.search.knn.KnnSearchStrategy;
import org.elasticsearch.search.profile.query.QueryProfiler;

public class ESDiversifyingChildrenByteKnnVectorQuery extends DiversifyingChildrenByteKnnVectorQuery implements QueryProfilerProvider {
Expand All @@ -25,9 +26,10 @@ public ESDiversifyingChildrenByteKnnVectorQuery(
Query childFilter,
Integer k,
int numCands,
BitSetProducer parentsFilter
BitSetProducer parentsFilter,
KnnSearchStrategy strategy
) {
super(field, query, childFilter, numCands, parentsFilter);
super(field, query, childFilter, numCands, parentsFilter, strategy);
this.kParam = k;
}

Expand All @@ -42,4 +44,8 @@ protected TopDocs mergeLeafResults(TopDocs[] perLeafResults) {
public void profile(QueryProfiler queryProfiler) {
queryProfiler.addVectorOpsCount(vectorOpsCount);
}

public KnnSearchStrategy getStrategy() {
return searchStrategy;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.join.BitSetProducer;
import org.apache.lucene.search.join.DiversifyingChildrenFloatKnnVectorQuery;
import org.apache.lucene.search.knn.KnnSearchStrategy;
import org.elasticsearch.search.profile.query.QueryProfiler;

public class ESDiversifyingChildrenFloatKnnVectorQuery extends DiversifyingChildrenFloatKnnVectorQuery implements QueryProfilerProvider {
Expand All @@ -25,9 +26,10 @@ public ESDiversifyingChildrenFloatKnnVectorQuery(
Query childFilter,
Integer k,
int numCands,
BitSetProducer parentsFilter
BitSetProducer parentsFilter,
KnnSearchStrategy strategy
) {
super(field, query, childFilter, numCands, parentsFilter);
super(field, query, childFilter, numCands, parentsFilter, strategy);
this.kParam = k;
}

Expand All @@ -42,4 +44,8 @@ protected TopDocs mergeLeafResults(TopDocs[] perLeafResults) {
public void profile(QueryProfiler queryProfiler) {
queryProfiler.addVectorOpsCount(vectorOpsCount);
}

public KnnSearchStrategy getStrategy() {
return searchStrategy;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
import org.apache.lucene.search.KnnByteVectorQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.knn.KnnSearchStrategy;
import org.elasticsearch.search.profile.query.QueryProfiler;

public class ESKnnByteVectorQuery extends KnnByteVectorQuery implements QueryProfilerProvider {
private final Integer kParam;
private long vectorOpsCount;

public ESKnnByteVectorQuery(String field, byte[] target, Integer k, int numCands, Query filter) {
super(field, target, numCands, filter);
public ESKnnByteVectorQuery(String field, byte[] target, Integer k, int numCands, Query filter, KnnSearchStrategy strategy) {
super(field, target, numCands, filter, strategy);
this.kParam = k;
}

Expand All @@ -39,4 +40,8 @@ public void profile(QueryProfiler queryProfiler) {
public Integer kParam() {
return kParam;
}

public KnnSearchStrategy getStrategy() {
return searchStrategy;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
import org.apache.lucene.search.KnnFloatVectorQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.knn.KnnSearchStrategy;
import org.elasticsearch.search.profile.query.QueryProfiler;

public class ESKnnFloatVectorQuery extends KnnFloatVectorQuery implements QueryProfilerProvider {
private final Integer kParam;
private long vectorOpsCount;

public ESKnnFloatVectorQuery(String field, float[] target, Integer k, int numCands, Query filter) {
super(field, target, numCands, filter);
public ESKnnFloatVectorQuery(String field, float[] target, Integer k, int numCands, Query filter, KnnSearchStrategy strategy) {
super(field, target, numCands, filter, strategy);
this.kParam = k;
}

Expand All @@ -39,4 +40,8 @@ public void profile(QueryProfiler queryProfiler) {
public Integer kParam() {
return kParam;
}

public KnnSearchStrategy getStrategy() {
return searchStrategy;
}
}
Loading
Loading