Skip to content

Bug: Vector index: inserts after CREATE_VECTOR_INDEX pick HNSW neighbours with cosine regardless of index metric #1023

Description

@vibl

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:

  1. Create a node table with an unnormalized FLOAT[d] column, insert a few rows, and create an index with metric := 'l2'.
  2. Insert many more rows with unnormalized vectors.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions