Ladybug version
main; vector extension at LadybugDB/extensions@2d5f07a
What happened?
I found this by reading the code and have not reproduced it at runtime. I don't use Ladybug myself, so please take this as an FYI.
Summary: rows inserted into a table after CREATE_VECTOR_INDEX (through CREATE/MERGE, or COPY FROM through finalize) get their HNSW neighbours chosen by cosine distance, whatever metric the index was created with. On l2 / l2sq / dotproduct indexes over non-normalized vectors, this should silently lower recall for those rows. Cosine indexes and unit-length vectors are unaffected.
Where (in vector/src/index/hnsw_index.cpp at 2d5f07a):
OnDiskHNSWIndex::HNSWInsertState::HNSWInsertState (L485-491) builds its searchState with a default-constructed HNSWIndexConfig{} instead of the index's config.
HNSWSearchState::HNSWSearchState (L477) sets metricFunc = embeddings->getMetricFunction(indexConfig.metric) from that config. The insert path therefore gets Metric::DEFAULT_VALUE = MetricType::Cosine (vector/src/include/index/hnsw_config.h L50).
insertInternal → searchNNInUpperLayer and insertToLayer → searchKNNInLayer use searchState.metricFunc to pick the neighbours.
- Callers affected:
OnDiskHNSWIndex::initInsertState (L719-724) and OnDiskHNSWIndex::finalize (L813-826). The update path (initUpdateState, L796-801) also goes through initInsertState, so updates to the embedding column probably pick neighbours by cosine too.
These parts use the correct metric: shrinkForNode (uses config.metric), query-time search (initQueryHNSWLocalState passes the real index config), and the initial build (InMemHNSWIndex).
Expected: incremental inserts should select neighbours with the index's configured metric.
Suggested fix: pass the index's config to HNSWInsertState (from initInsertState and finalize) and use it instead of HNSWIndexConfig{} when constructing searchState.
Related (minor): the same constructor passes QueryHNSWConfig{}, so post-build inserts search with the default efs (200) instead of the index's efc. With default parameters this changes nothing. With a custom efc, the value only affects the initial build.
Are there known steps to reproduce?
Not run, but this should show it:
- Create a node table with an unnormalized
FLOAT[d] column, insert a few rows, and create an index with metric := 'l2'.
- Insert many more rows with unnormalized vectors.
- Compare
QUERY_VECTOR_INDEX recall against use_knn := true, and against the same data indexed after loading.
Workaround: drop and recreate the index after bulk inserts.
Ladybug version
main; vector extension at LadybugDB/extensions@2d5f07aWhat happened?
I found this by reading the code and have not reproduced it at runtime. I don't use Ladybug myself, so please take this as an FYI.
Summary: rows inserted into a table after
CREATE_VECTOR_INDEX(throughCREATE/MERGE, orCOPY FROMthroughfinalize) get their HNSW neighbours chosen by cosine distance, whatevermetricthe index was created with. Onl2/l2sq/dotproductindexes over non-normalized vectors, this should silently lower recall for those rows. Cosine indexes and unit-length vectors are unaffected.Where (in
vector/src/index/hnsw_index.cppat2d5f07a):OnDiskHNSWIndex::HNSWInsertState::HNSWInsertState(L485-491) builds itssearchStatewith a default-constructedHNSWIndexConfig{}instead of the index's config.HNSWSearchState::HNSWSearchState(L477) setsmetricFunc = embeddings->getMetricFunction(indexConfig.metric)from that config. The insert path therefore getsMetric::DEFAULT_VALUE=MetricType::Cosine(vector/src/include/index/hnsw_config.hL50).insertInternal→searchNNInUpperLayerandinsertToLayer→searchKNNInLayerusesearchState.metricFuncto pick the neighbours.OnDiskHNSWIndex::initInsertState(L719-724) andOnDiskHNSWIndex::finalize(L813-826). The update path (initUpdateState, L796-801) also goes throughinitInsertState, so updates to the embedding column probably pick neighbours by cosine too.These parts use the correct metric:
shrinkForNode(usesconfig.metric), query-time search (initQueryHNSWLocalStatepasses the real index config), and the initial build (InMemHNSWIndex).Expected: incremental inserts should select neighbours with the index's configured metric.
Suggested fix: pass the index's
configtoHNSWInsertState(frominitInsertStateandfinalize) and use it instead ofHNSWIndexConfig{}when constructingsearchState.Related (minor): the same constructor passes
QueryHNSWConfig{}, so post-build inserts search with the defaultefs(200) instead of the index'sefc. With default parameters this changes nothing. With a customefc, the value only affects the initial build.Are there known steps to reproduce?
Not run, but this should show it:
FLOAT[d]column, insert a few rows, and create an index withmetric := 'l2'.QUERY_VECTOR_INDEXrecall againstuse_knn := true, and against the same data indexed after loading.Workaround: drop and recreate the index after bulk inserts.