Skip to content

Commit 50d8c75

Browse files
benwtrentywangd
authored andcommitted
Improve HNSW filtered search speed through new heuristic (elastic#126876)
Apache Lucene 10.2 exposes a new search strategy for executing filtered searches over HNSW graphs. This PR switches to utilizing that strategy by default as it generally provides a much better recall/latency pareto frontier than our regular hnsw fanout search. Additionally, a new tech-preview setting is provided to potentially revert to the old fanout behavior if issues arise.
1 parent 4d799db commit 50d8c75

File tree

15 files changed

+412
-51
lines changed

15 files changed

+412
-51
lines changed

docs/changelog/126876.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 126876
2+
summary: Improve HNSW filtered search speed through new heuristic
3+
area: Vector Search
4+
type: enhancement
5+
issues: []

docs/reference/elasticsearch/index-settings/index-modules.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@ $$$index-final-pipeline$$$
249249
$$$index-hidden$$$ `index.hidden`
250250
: 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).
251251

252+
$$$index-dense-vector-hnsw-filter-heuristic$$$ `index.dense_vector.hnsw_filter_heuristic`
253+
: 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:
254+
255+
* `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.
256+
* `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.
257+
252258
$$$index-esql-stored-fields-sequential-proportion$$$
253259

254260
`index.esql.stored_fields_sequential_proportion`

server/src/main/java/org/elasticsearch/common/settings/IndexScopedSettings.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.elasticsearch.index.mapper.IgnoredSourceFieldMapper;
3737
import org.elasticsearch.index.mapper.InferenceMetadataFieldsMapper;
3838
import org.elasticsearch.index.mapper.MapperService;
39+
import org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper;
3940
import org.elasticsearch.index.similarity.SimilarityService;
4041
import org.elasticsearch.index.store.FsDirectoryFactory;
4142
import org.elasticsearch.index.store.Store;
@@ -157,6 +158,7 @@ public final class IndexScopedSettings extends AbstractScopedSettings {
157158
IndexSettings.INDEX_TRANSLOG_RETENTION_AGE_SETTING,
158159
IndexSettings.INDEX_TRANSLOG_RETENTION_SIZE_SETTING,
159160
IndexSettings.INDEX_SEARCH_IDLE_AFTER,
161+
DenseVectorFieldMapper.HNSW_FILTER_HEURISTIC,
160162
IndexFieldDataService.INDEX_FIELDDATA_CACHE_KEY,
161163
IndexSettings.IGNORE_ABOVE_SETTING,
162164
FieldMapper.IGNORE_MALFORMED_SETTING,

server/src/main/java/org/elasticsearch/index/IndexSettings.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.elasticsearch.index.mapper.IgnoredSourceFieldMapper;
3030
import org.elasticsearch.index.mapper.Mapper;
3131
import org.elasticsearch.index.mapper.SourceFieldMapper;
32+
import org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper;
3233
import org.elasticsearch.index.translog.Translog;
3334
import org.elasticsearch.indices.recovery.RecoverySettings;
3435
import org.elasticsearch.ingest.IngestService;
@@ -896,6 +897,7 @@ private void setRetentionLeaseMillis(final TimeValue retentionLease) {
896897
private volatile int maxTokenCount;
897898
private volatile int maxNgramDiff;
898899
private volatile int maxShingleDiff;
900+
private volatile DenseVectorFieldMapper.FilterHeuristic hnswFilterHeuristic;
899901
private volatile TimeValue searchIdleAfter;
900902
private volatile int maxAnalyzedOffset;
901903
private volatile boolean weightMatchesEnabled;
@@ -1091,6 +1093,7 @@ public IndexSettings(final IndexMetadata indexMetadata, final Settings nodeSetti
10911093
logsdbAddHostNameField = scopedSettings.get(LOGSDB_ADD_HOST_NAME_FIELD);
10921094
skipIgnoredSourceWrite = scopedSettings.get(IgnoredSourceFieldMapper.SKIP_IGNORED_SOURCE_WRITE_SETTING);
10931095
skipIgnoredSourceRead = scopedSettings.get(IgnoredSourceFieldMapper.SKIP_IGNORED_SOURCE_READ_SETTING);
1096+
hnswFilterHeuristic = scopedSettings.get(DenseVectorFieldMapper.HNSW_FILTER_HEURISTIC);
10941097
indexMappingSourceMode = scopedSettings.get(INDEX_MAPPER_SOURCE_MODE_SETTING);
10951098
recoverySourceEnabled = RecoverySettings.INDICES_RECOVERY_SOURCE_ENABLED_SETTING.get(nodeSettings);
10961099
recoverySourceSyntheticEnabled = DiscoveryNode.isStateless(nodeSettings) == false
@@ -1203,6 +1206,7 @@ public IndexSettings(final IndexMetadata indexMetadata, final Settings nodeSetti
12031206
this::setSkipIgnoredSourceWrite
12041207
);
12051208
scopedSettings.addSettingsUpdateConsumer(IgnoredSourceFieldMapper.SKIP_IGNORED_SOURCE_READ_SETTING, this::setSkipIgnoredSourceRead);
1209+
scopedSettings.addSettingsUpdateConsumer(DenseVectorFieldMapper.HNSW_FILTER_HEURISTIC, this::setHnswFilterHeuristic);
12061210
}
12071211

12081212
private void setSearchIdleAfter(TimeValue searchIdleAfter) {
@@ -1821,4 +1825,16 @@ public TimestampBounds getTimestampBounds() {
18211825
public IndexRouting getIndexRouting() {
18221826
return indexRouting;
18231827
}
1828+
1829+
/**
1830+
* The heuristic to utilize when executing filtered search on vectors indexed
1831+
* in HNSW format.
1832+
*/
1833+
public DenseVectorFieldMapper.FilterHeuristic getHnswFilterHeuristic() {
1834+
return this.hnswFilterHeuristic;
1835+
}
1836+
1837+
private void setHnswFilterHeuristic(DenseVectorFieldMapper.FilterHeuristic heuristic) {
1838+
this.hnswFilterHeuristic = heuristic;
1839+
}
18241840
}

server/src/main/java/org/elasticsearch/index/IndexVersions.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ private static Version parseUnchecked(String version) {
166166
public static final IndexVersion UPGRADE_TO_LUCENE_10_2_1 = def(9_023_00_0, Version.LUCENE_10_2_1);
167167
public static final IndexVersion DEFAULT_OVERSAMPLE_VALUE_FOR_BBQ = def(9_024_0_00, Version.LUCENE_10_2_1);
168168
public static final IndexVersion SEMANTIC_TEXT_DEFAULTS_TO_BBQ = def(9_025_0_00, Version.LUCENE_10_2_1);
169+
public static final IndexVersion DEFAULT_TO_ACORN_HNSW_FILTER_HEURISTIC = def(9_026_0_00, Version.LUCENE_10_2_1);
169170
/*
170171
* STOP! READ THIS FIRST! No, really,
171172
* ____ _____ ___ ____ _ ____ _____ _ ____ _____ _ _ ___ ____ _____ ___ ____ ____ _____ _

server/src/main/java/org/elasticsearch/index/mapper/vectors/DenseVectorFieldMapper.java

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@
3333
import org.apache.lucene.search.FieldExistsQuery;
3434
import org.apache.lucene.search.Query;
3535
import org.apache.lucene.search.join.BitSetProducer;
36+
import org.apache.lucene.search.knn.KnnSearchStrategy;
3637
import org.apache.lucene.util.BitUtil;
3738
import org.apache.lucene.util.BytesRef;
3839
import org.apache.lucene.util.VectorUtil;
3940
import org.elasticsearch.common.ParsingException;
41+
import org.elasticsearch.common.settings.Setting;
4042
import org.elasticsearch.common.xcontent.support.XContentMapValues;
4143
import org.elasticsearch.features.NodeFeature;
4244
import org.elasticsearch.index.IndexVersion;
@@ -93,6 +95,7 @@
9395
import java.util.function.Supplier;
9496
import java.util.stream.Stream;
9597

98+
import static org.elasticsearch.cluster.metadata.IndexMetadata.SETTING_INDEX_VERSION_CREATED;
9699
import static org.elasticsearch.common.Strings.format;
97100
import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
98101

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

114+
/**
115+
* The heuristic to utilize when executing a filtered search against vectors indexed in an HNSW graph.
116+
*/
117+
public enum FilterHeuristic {
118+
/**
119+
* This heuristic searches the entire graph, doing vector comparisons in all immediate neighbors
120+
* but only collects vectors that match the filtering criteria.
121+
*/
122+
FANOUT {
123+
static final KnnSearchStrategy FANOUT_STRATEGY = new KnnSearchStrategy.Hnsw(0);
124+
125+
@Override
126+
public KnnSearchStrategy getKnnSearchStrategy() {
127+
return FANOUT_STRATEGY;
128+
}
129+
},
130+
/**
131+
* This heuristic will only compare vectors that match the filtering criteria.
132+
*/
133+
ACORN {
134+
static final KnnSearchStrategy ACORN_STRATEGY = new KnnSearchStrategy.Hnsw(60);
135+
136+
@Override
137+
public KnnSearchStrategy getKnnSearchStrategy() {
138+
return ACORN_STRATEGY;
139+
}
140+
};
141+
142+
public abstract KnnSearchStrategy getKnnSearchStrategy();
143+
}
144+
145+
public static final Setting<FilterHeuristic> HNSW_FILTER_HEURISTIC = Setting.enumSetting(FilterHeuristic.class, s -> {
146+
IndexVersion version = SETTING_INDEX_VERSION_CREATED.get(s);
147+
if (version.onOrAfter(IndexVersions.DEFAULT_TO_ACORN_HNSW_FILTER_HEURISTIC)) {
148+
return FilterHeuristic.ACORN.toString();
149+
}
150+
return FilterHeuristic.FANOUT.toString();
151+
},
152+
"index.dense_vector.hnsw_filter_heuristic",
153+
fh -> {},
154+
Setting.Property.IndexScope,
155+
Setting.Property.ServerlessPublic,
156+
Setting.Property.Dynamic
157+
);
158+
111159
private static boolean hasRescoreIndexVersion(IndexVersion version) {
112160
return version.onOrAfter(IndexVersions.ADD_RESCORE_PARAMS_TO_QUANTIZED_VECTORS)
113161
|| version.between(IndexVersions.ADD_RESCORE_PARAMS_TO_QUANTIZED_VECTORS_BACKPORT_8_X, IndexVersions.UPGRADE_TO_LUCENE_10_0_0);
@@ -2210,25 +2258,44 @@ public Query createKnnQuery(
22102258
Float oversample,
22112259
Query filter,
22122260
Float similarityThreshold,
2213-
BitSetProducer parentFilter
2261+
BitSetProducer parentFilter,
2262+
DenseVectorFieldMapper.FilterHeuristic heuristic
22142263
) {
22152264
if (isIndexed() == false) {
22162265
throw new IllegalArgumentException(
22172266
"to perform knn search on field [" + name() + "], its mapping must have [index] set to [true]"
22182267
);
22192268
}
2269+
KnnSearchStrategy knnSearchStrategy = heuristic.getKnnSearchStrategy();
22202270
return switch (getElementType()) {
2221-
case BYTE -> createKnnByteQuery(queryVector.asByteVector(), k, numCands, filter, similarityThreshold, parentFilter);
2271+
case BYTE -> createKnnByteQuery(
2272+
queryVector.asByteVector(),
2273+
k,
2274+
numCands,
2275+
filter,
2276+
similarityThreshold,
2277+
parentFilter,
2278+
knnSearchStrategy
2279+
);
22222280
case FLOAT -> createKnnFloatQuery(
22232281
queryVector.asFloatVector(),
22242282
k,
22252283
numCands,
22262284
oversample,
22272285
filter,
22282286
similarityThreshold,
2229-
parentFilter
2287+
parentFilter,
2288+
knnSearchStrategy
2289+
);
2290+
case BIT -> createKnnBitQuery(
2291+
queryVector.asByteVector(),
2292+
k,
2293+
numCands,
2294+
filter,
2295+
similarityThreshold,
2296+
parentFilter,
2297+
knnSearchStrategy
22302298
);
2231-
case BIT -> createKnnBitQuery(queryVector.asByteVector(), k, numCands, filter, similarityThreshold, parentFilter);
22322299
};
22332300
}
22342301

@@ -2246,12 +2313,13 @@ private Query createKnnBitQuery(
22462313
int numCands,
22472314
Query filter,
22482315
Float similarityThreshold,
2249-
BitSetProducer parentFilter
2316+
BitSetProducer parentFilter,
2317+
KnnSearchStrategy searchStrategy
22502318
) {
22512319
elementType.checkDimensions(dims, queryVector.length);
22522320
Query knnQuery = parentFilter != null
2253-
? new ESDiversifyingChildrenByteKnnVectorQuery(name(), queryVector, filter, k, numCands, parentFilter)
2254-
: new ESKnnByteVectorQuery(name(), queryVector, k, numCands, filter);
2321+
? new ESDiversifyingChildrenByteKnnVectorQuery(name(), queryVector, filter, k, numCands, parentFilter, searchStrategy)
2322+
: new ESKnnByteVectorQuery(name(), queryVector, k, numCands, filter, searchStrategy);
22552323
if (similarityThreshold != null) {
22562324
knnQuery = new VectorSimilarityQuery(
22572325
knnQuery,
@@ -2268,7 +2336,8 @@ private Query createKnnByteQuery(
22682336
int numCands,
22692337
Query filter,
22702338
Float similarityThreshold,
2271-
BitSetProducer parentFilter
2339+
BitSetProducer parentFilter,
2340+
KnnSearchStrategy searchStrategy
22722341
) {
22732342
elementType.checkDimensions(dims, queryVector.length);
22742343

@@ -2277,8 +2346,8 @@ private Query createKnnByteQuery(
22772346
elementType.checkVectorMagnitude(similarity, ElementType.errorByteElementsAppender(queryVector), squaredMagnitude);
22782347
}
22792348
Query knnQuery = parentFilter != null
2280-
? new ESDiversifyingChildrenByteKnnVectorQuery(name(), queryVector, filter, k, numCands, parentFilter)
2281-
: new ESKnnByteVectorQuery(name(), queryVector, k, numCands, filter);
2349+
? new ESDiversifyingChildrenByteKnnVectorQuery(name(), queryVector, filter, k, numCands, parentFilter, searchStrategy)
2350+
: new ESKnnByteVectorQuery(name(), queryVector, k, numCands, filter, searchStrategy);
22822351
if (similarityThreshold != null) {
22832352
knnQuery = new VectorSimilarityQuery(
22842353
knnQuery,
@@ -2296,7 +2365,8 @@ private Query createKnnFloatQuery(
22962365
Float queryOversample,
22972366
Query filter,
22982367
Float similarityThreshold,
2299-
BitSetProducer parentFilter
2368+
BitSetProducer parentFilter,
2369+
KnnSearchStrategy knnSearchStrategy
23002370
) {
23012371
elementType.checkDimensions(dims, queryVector.length);
23022372
elementType.checkVectorBounds(queryVector);
@@ -2330,8 +2400,16 @@ && isNotUnitVector(squaredMagnitude)) {
23302400
numCands = Math.max(adjustedK, numCands);
23312401
}
23322402
Query knnQuery = parentFilter != null
2333-
? new ESDiversifyingChildrenFloatKnnVectorQuery(name(), queryVector, filter, adjustedK, numCands, parentFilter)
2334-
: new ESKnnFloatVectorQuery(name(), queryVector, adjustedK, numCands, filter);
2403+
? new ESDiversifyingChildrenFloatKnnVectorQuery(
2404+
name(),
2405+
queryVector,
2406+
filter,
2407+
adjustedK,
2408+
numCands,
2409+
parentFilter,
2410+
knnSearchStrategy
2411+
)
2412+
: new ESKnnFloatVectorQuery(name(), queryVector, adjustedK, numCands, filter, knnSearchStrategy);
23352413
if (rescore) {
23362414
knnQuery = new RescoreKnnVectorQuery(
23372415
name(),

server/src/main/java/org/elasticsearch/search/vectors/ESDiversifyingChildrenByteKnnVectorQuery.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.apache.lucene.search.TopDocs;
1414
import org.apache.lucene.search.join.BitSetProducer;
1515
import org.apache.lucene.search.join.DiversifyingChildrenByteKnnVectorQuery;
16+
import org.apache.lucene.search.knn.KnnSearchStrategy;
1617
import org.elasticsearch.search.profile.query.QueryProfiler;
1718

1819
public class ESDiversifyingChildrenByteKnnVectorQuery extends DiversifyingChildrenByteKnnVectorQuery implements QueryProfilerProvider {
@@ -25,9 +26,10 @@ public ESDiversifyingChildrenByteKnnVectorQuery(
2526
Query childFilter,
2627
Integer k,
2728
int numCands,
28-
BitSetProducer parentsFilter
29+
BitSetProducer parentsFilter,
30+
KnnSearchStrategy strategy
2931
) {
30-
super(field, query, childFilter, numCands, parentsFilter);
32+
super(field, query, childFilter, numCands, parentsFilter, strategy);
3133
this.kParam = k;
3234
}
3335

@@ -42,4 +44,8 @@ protected TopDocs mergeLeafResults(TopDocs[] perLeafResults) {
4244
public void profile(QueryProfiler queryProfiler) {
4345
queryProfiler.addVectorOpsCount(vectorOpsCount);
4446
}
47+
48+
public KnnSearchStrategy getStrategy() {
49+
return searchStrategy;
50+
}
4551
}

server/src/main/java/org/elasticsearch/search/vectors/ESDiversifyingChildrenFloatKnnVectorQuery.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.apache.lucene.search.TopDocs;
1414
import org.apache.lucene.search.join.BitSetProducer;
1515
import org.apache.lucene.search.join.DiversifyingChildrenFloatKnnVectorQuery;
16+
import org.apache.lucene.search.knn.KnnSearchStrategy;
1617
import org.elasticsearch.search.profile.query.QueryProfiler;
1718

1819
public class ESDiversifyingChildrenFloatKnnVectorQuery extends DiversifyingChildrenFloatKnnVectorQuery implements QueryProfilerProvider {
@@ -25,9 +26,10 @@ public ESDiversifyingChildrenFloatKnnVectorQuery(
2526
Query childFilter,
2627
Integer k,
2728
int numCands,
28-
BitSetProducer parentsFilter
29+
BitSetProducer parentsFilter,
30+
KnnSearchStrategy strategy
2931
) {
30-
super(field, query, childFilter, numCands, parentsFilter);
32+
super(field, query, childFilter, numCands, parentsFilter, strategy);
3133
this.kParam = k;
3234
}
3335

@@ -42,4 +44,8 @@ protected TopDocs mergeLeafResults(TopDocs[] perLeafResults) {
4244
public void profile(QueryProfiler queryProfiler) {
4345
queryProfiler.addVectorOpsCount(vectorOpsCount);
4446
}
47+
48+
public KnnSearchStrategy getStrategy() {
49+
return searchStrategy;
50+
}
4551
}

server/src/main/java/org/elasticsearch/search/vectors/ESKnnByteVectorQuery.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@
1212
import org.apache.lucene.search.KnnByteVectorQuery;
1313
import org.apache.lucene.search.Query;
1414
import org.apache.lucene.search.TopDocs;
15+
import org.apache.lucene.search.knn.KnnSearchStrategy;
1516
import org.elasticsearch.search.profile.query.QueryProfiler;
1617

1718
public class ESKnnByteVectorQuery extends KnnByteVectorQuery implements QueryProfilerProvider {
1819
private final Integer kParam;
1920
private long vectorOpsCount;
2021

21-
public ESKnnByteVectorQuery(String field, byte[] target, Integer k, int numCands, Query filter) {
22-
super(field, target, numCands, filter);
22+
public ESKnnByteVectorQuery(String field, byte[] target, Integer k, int numCands, Query filter, KnnSearchStrategy strategy) {
23+
super(field, target, numCands, filter, strategy);
2324
this.kParam = k;
2425
}
2526

@@ -39,4 +40,8 @@ public void profile(QueryProfiler queryProfiler) {
3940
public Integer kParam() {
4041
return kParam;
4142
}
43+
44+
public KnnSearchStrategy getStrategy() {
45+
return searchStrategy;
46+
}
4247
}

server/src/main/java/org/elasticsearch/search/vectors/ESKnnFloatVectorQuery.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@
1212
import org.apache.lucene.search.KnnFloatVectorQuery;
1313
import org.apache.lucene.search.Query;
1414
import org.apache.lucene.search.TopDocs;
15+
import org.apache.lucene.search.knn.KnnSearchStrategy;
1516
import org.elasticsearch.search.profile.query.QueryProfiler;
1617

1718
public class ESKnnFloatVectorQuery extends KnnFloatVectorQuery implements QueryProfilerProvider {
1819
private final Integer kParam;
1920
private long vectorOpsCount;
2021

21-
public ESKnnFloatVectorQuery(String field, float[] target, Integer k, int numCands, Query filter) {
22-
super(field, target, numCands, filter);
22+
public ESKnnFloatVectorQuery(String field, float[] target, Integer k, int numCands, Query filter, KnnSearchStrategy strategy) {
23+
super(field, target, numCands, filter, strategy);
2324
this.kParam = k;
2425
}
2526

@@ -39,4 +40,8 @@ public void profile(QueryProfiler queryProfiler) {
3940
public Integer kParam() {
4041
return kParam;
4142
}
43+
44+
public KnnSearchStrategy getStrategy() {
45+
return searchStrategy;
46+
}
4247
}

0 commit comments

Comments
 (0)