Skip to content

Neo4j vector similarity search is O(N) brute-force; HNSW support was added in #859 then removed in #894 #1793

Description

@xiaoyaner0201

Neo4j vector similarity search is O(N) brute-force; HNSW support was added in #859 then removed in #894

Summary

On the Neo4j backend, every vector similarity search computes vector.similarity.cosine() against every matching node/edge in the graph. There is no vector index, and build_indices_and_constraints() never creates one.

On a modest graph (48,647 Entity nodes / 76,264 RELATES_TO edges, 2560-dim embeddings) this makes a single edge_similarity_search take ~41 seconds. Recreating the same query against a manually-created Neo4j HNSW index returns the identical top-20 result set in ~82 ms — a ~500x difference.

Neo4j has supported native vector indexes and db.index.vector.queryRelationships / queryNodes since 5.18. Graphiti currently does not use them on the Neo4j path.

Current behavior

graphiti_core/graph_queries.py (line numbers from 993e081, current main):

def get_vector_cosine_func_query(vec1, vec2, provider: GraphProvider) -> str:   # L155
    if provider == GraphProvider.FALKORDB:
        return f'(2 - vec.cosineDistance({vec1}, vecf32({vec2})))/2'
    if provider == GraphProvider.KUZU:
        return f'array_cosine_similarity({vec1}, {vec2})'
    return f'vector.similarity.cosine({vec1}, {vec2})'                          # L163  ← Neo4j

This helper is called from 11 sites in graphiti_core/search/search_utils.py, including the three primary search entry points:

Line Function
413 edge_similarity_search
748 node_similarity_search
1137 community_similarity_search
1280, 1327, 1490, 1528, 1677, 1716 hybrid / combined search paths

Neo4jDriver.build_indices_and_constraints() creates range and fulltext indexes only:

range_indices: list[LiteralString] = get_range_indices(self.provider)
fulltext_indices: list[LiteralString] = get_fulltext_indices(self.provider)
index_queries: list[LiteralString] = range_indices + fulltext_indices

A repo-wide search confirms the gap on current main:

  • db.index.vector0 hits
  • CREATE VECTOR INDEX → 1 hit, in examples/ecommerce/runner.ipynb only

This was implemented once and then removed

This is not a feature that was never attempted:

  • use hnsw indexes #859 "use hnsw indexes" (merged 2025-08-25) introduced a USE_HNSW env flag, a CREATE VECTOR INDEX ... OPTIONS { indexConfig: { \vector.dimensions`: 1024, `vector.similarity_function`: 'cosine' }}statement, and aGraphProvider.NEO4J and USE_HNSWbranch innode_similarity_searchusingCALL db.index.vector.queryNodes(...)`.
  • cleanup #894 "cleanup" (1f5a1b8, 2025-09-05) removed USE_HNSW along with the associated branches.

USE_HNSW has 0 occurrences on current main.

I could not find a recorded rationale for the removal, so this issue is written as a question as much as a report: was the HNSW path dropped intentionally (e.g. hardcoded 1024 dimensions, per-group index naming, recall concerns), or did it fall out as collateral during cleanup? The answer determines whether a re-introduction is welcome and what shape it should take.

Reproduction / measurements

Environment:

  • graphiti-core 0.29.2 (behavior verified verbatim against 993e081 on main)
  • Neo4j 5.26.26 Community, heap 8G, pagecache 8G
  • 48,647 Entity nodes, 76,264 RELATES_TO edges, fact_embedding dimension 2560
  • No other workload on the database (the Graphiti MCP server was stopped for the benchmark; with it running, concurrent brute-force scans saturated ~29 CPU-equivalents and made all timings unusable)

All queries issued over the HTTP transaction endpoint. Baseline RETURN 1 measured at 149–162 ms; subtract that from the numbers below.

A — current behavior (brute-force):

WITH [x IN range(1,2560) | rand()] AS v
MATCH (n:Entity)-[e:RELATES_TO]->(m:Entity)
WHERE e.group_id IN ['<group>']
WITH DISTINCT e, n, m, vector.similarity.cosine(e.fact_embedding, v) AS score
WHERE score > 0.0
RETURN e.uuid AS uuid, n.uuid AS src, m.uuid AS dst, score
ORDER BY score DESC LIMIT 20

45208 ms / 42723 ms / 41014 ms

B — same result via HNSW index:

CREATE VECTOR INDEX edge_fact_embedding_hnsw IF NOT EXISTS
FOR ()-[e:RELATES_TO]-() ON (e.fact_embedding)
OPTIONS {indexConfig: {`vector.dimensions`: 2560, `vector.similarity_function`: 'cosine'}}
WITH [x IN range(1,2560) | rand()] AS v
CALL db.index.vector.queryRelationships('edge_fact_embedding_hnsw', 60, v)
YIELD relationship AS e, score
WHERE e.group_id IN ['<group>']
RETURN e.uuid AS uuid, startNode(e).uuid AS src, endNode(e).uuid AS dst, score
ORDER BY score DESC LIMIT 20

1456 ms (cold) / 390 ms / 232 ms

Index build took ~5 minutes for 76,264 edges and reached state=ONLINE, populationPercent=100.0.

Recall check. Using a real edge's fact_embedding as the query vector, top-20 from A and top-20 from B were compared as sets:

brute-force returned: 20
HNSW returned:        20
intersection:         20
only in brute-force:  (none)
only in HNSW:         (none)

This is a single seed, so it is evidence rather than proof — HNSW is approximate by construction and a broader recall sweep would be needed to characterize it properly. Over-fetching (limit * 3) before post-filtering is what makes the group filter safe here, matching the approach in #1335.

Why this matters beyond raw latency

The cost is not paid once per user query. While the Graphiti MCP server was running normally, SHOW TRANSACTIONS consistently showed 5+ concurrent brute-force scans, each already 2+ minutes elapsed, with Neo4j pinned at multiple thousand percent CPU. Stopping the MCP server dropped Neo4j to 0.85% CPU. On the Neo4j path the cost scales with graph size × concurrency, so it degrades exactly as a memory graph becomes worth having.

Related work

Both target FalkorDB. Neo4j — the default backend in the README quickstart — has no equivalent open work that I could find, despite having had a partial implementation in-tree a year ago.

Proposed direction

Assuming the removal in #894 was not a deliberate rejection, a re-introduction would need to address the reasons the original was fragile:

  1. Create HNSW vector indexes in Neo4jDriver.build_indices_and_constraints() — on Entity.name_embedding, RELATES_TO.fact_embedding, and Community.name_embedding. Dimension must be derived from the configured embedder rather than hardcoded (use hnsw indexes #859 hardcoded 1024; this deployment uses 2560).
  2. Route Neo4j similarity searches through db.index.vector.queryNodes / queryRelationships with over-fetch + post-filter, so group_ids and SearchFilters semantics are preserved.
  3. Keep the brute-force path available for exact-recall requirements and for graphs whose index has not been built, rather than reintroducing a bare env flag as the only control.

I have the benchmark harness and a fork set up, and I am happy to open a PR for the Neo4j path. Before writing it I would rather have maintainer input on two points, since they determine the shape of the change:

  • Is a Neo4j HNSW path wanted at all, given cleanup #894 removed the previous one?
  • Should index creation be automatic in build_indices_and_constraints(), or opt-in? Automatic creation on an existing large graph triggers a background population that is expensive once; opt-in avoids surprising existing deployments but leaves the default slow.

Happy to provide the full benchmark scripts, or to run a wider recall sweep across many seeds if that would help the decision.

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