Neo4j developer on retainer: vector index returns 0 results — index defined on property embedding but nodes inserted with embedding_vector — queryNodes() silently returns empty — 3 weeks — Neo4j AuraDB vector search developer on monthly retainer

Neo4j AuraDB • neo4j Python driver 5.18.x • Python 3.11 • VECTOR INDEX on (n:Document) ON (n.embedding) • 1,536 dims • cosine similarity • all nodes inserted with property embedding_vector • SHOW INDEXES state=ONLINE, populationPercent=0.0 • db.index.vector.queryNodes() returns 0 • no error raised • 3 weeks • fix: SET n.embedding = n.embedding_vector for all nodes; update ingestion code • 1.5h retainer engagement

Westfield Knowledge Graph Ltd. built a knowledge graph RAG pipeline in September 2026 using Neo4j AuraDB Professional with Neo4j 5.20. The schema migration script created a VECTOR INDEX named document_embeddings on the embedding property of Document nodes. A separate ingestion service, developed in a different sprint, inserted all Document nodes using the property name embedding_vector — a naming convention the ingestion team used for vector columns in other parts of the system. The vector index showed status ONLINE in the Neo4j Browser and SHOW INDEXES returned no errors. Every call to db.index.vector.queryNodes() returned zero results. The developer spent three weeks checking the similarity function, verifying that the query vector had unit norm, reviewing the AuraDB tier documentation, and re-running the ingestion pipeline — all without results. They engaged a Neo4j retainer developer.

The schema and ingestion code at the time of the engagement:

# graph_store.py
# Westfield Knowledge Graph Ltd.
# neo4j Python driver 5.18.x, Python 3.11, Neo4j AuraDB 5.20

from neo4j import GraphDatabase

URI      = "neo4j+s://xxxxxxxx.databases.neo4j.io"
AUTH     = ("neo4j", "...")
DRIVER   = GraphDatabase.driver(URI, auth=AUTH)

# ---- STEP 1: Schema migration (run once) ----
# Creates the vector index on property 'embedding'

SETUP_QUERIES = [
    """
    CREATE VECTOR INDEX document_embeddings IF NOT EXISTS
    FOR (n:Document)
    ON (n.embedding)
    OPTIONS {
        indexConfig: {
            `vector.dimensions`: 1536,
            `vector.similarity_function`: 'cosine'
        }
    }
    """,
]

def run_schema_migration():
    with DRIVER.session(database="neo4j") as session:
        for q in SETUP_QUERIES:
            session.run(q)
    # Index created: document_embeddings, state=ONLINE (structure ready)
    # populationPercent = 0.0 ← no Document nodes exist yet at migration time;
    #                           will populate as nodes are added with 'embedding' property

# ---- STEP 2: Document ingestion (separate service) ----
# Inserts Document nodes with property 'embedding_vector' — NOT 'embedding'

def insert_documents(docs: list[dict]) -> int:
    with DRIVER.session(database="neo4j") as session:
        result = session.run(
            """
            UNWIND $docs AS doc
            MERGE (n:Document {doc_id: doc.doc_id})
            SET n.title          = doc.title,
                n.content        = doc.content,
                n.embedding_vector = doc.embedding  -- ← BUG: property name is 'embedding_vector'
                                                     --   index expects property 'embedding'
                                                     --   Neo4j does NOT warn that the property
                                                     --   does not match the index definition
            RETURN count(n) AS nodes_created
            """,
            docs=docs
        )
        summary = result.single()
        return summary["nodes_created"]
        # Returns 500: 500 Document nodes created ✓
        # But all nodes have property 'embedding_vector', not 'embedding'
        # The index 'document_embeddings' indexes nodes with property 'embedding'
        # None of the 500 nodes have 'embedding' set → index remains empty

# ---- STEP 3: Vector similarity search ----

def search_documents(query_embedding: list[float], top_k: int = 10) -> list[dict]:
    with DRIVER.session(database="neo4j") as session:
        result = session.run(
            """
            CALL db.index.vector.queryNodes(
                'document_embeddings',
                $top_k,
                $query_embedding
            )
            YIELD node, score
            RETURN node.doc_id AS doc_id,
                   node.title  AS title,
                   score
            ORDER BY score DESC
            """,
            top_k=top_k,
            query_embedding=query_embedding,
        )
        rows = result.data()
        # Expected: len(rows) == top_k == 10
        # Actual:   len(rows) == 0   ← index is empty; no nodes have 'embedding' property
        # No exception raised; query is syntactically correct; Cypher returns empty result set
        return rows

Why the index is silently empty

Neo4j's VECTOR INDEX works like any other Neo4j index — it watches for nodes with a specific label and a specific property name. When a Document node is created or modified, Neo4j evaluates whether the node has the embedding property. If it does, that node is added to the document_embeddings index. If the node exists but has a different property (such as embedding_vector), Neo4j does not raise an error — the node simply is not indexed.

The index status ONLINE reported by SHOW INDEXES does not mean the index contains any data. It means the index structure has been created and Neo4j is actively watching for matching nodes. The populationPercent field is the correct signal: a value of 0.0 with a non-empty database indicates that none of the existing nodes match the label-and-property combination the index is watching. The developer never inspected populationPercent because the Neo4j Browser's index panel surfaces only the state column, and state=ONLINE looked like success.

# Diagnosing the property name mismatch — Cypher queries

-- Step 1: Check index population state
SHOW INDEXES YIELD name, state, populationPercent
WHERE name = 'document_embeddings';
// name: document_embeddings | state: ONLINE | populationPercent: 0.0
// ← ONLINE but 0% populated: index has zero indexed nodes despite 500 Document nodes in DB

-- Step 2: Count nodes with the indexed property ('embedding')
MATCH (n:Document)
WHERE n.embedding IS NOT NULL
RETURN count(n) AS nodes_with_embedding_property;
// count: 0   ← zero nodes have 'embedding' set

-- Step 3: Count nodes with the actual property used in ingestion
MATCH (n:Document)
WHERE n.embedding_vector IS NOT NULL
RETURN count(n) AS nodes_with_embedding_vector;
// count: 500   ← all 500 nodes have 'embedding_vector'

-- Step 4: Inspect actual properties on one node
MATCH (n:Document) RETURN keys(n) LIMIT 1;
// ['doc_id', 'title', 'content', 'embedding_vector']
// 'embedding' is absent — confirms the property name mismatch

The fix: align property names and verify index population

There are two ways to fix the mismatch: update the existing nodes to add the correct property, or drop the index and recreate it on the correct property. Updating the nodes is faster for an existing dataset — a single Cypher SET n.embedding = n.embedding_vector query adds the indexed property to all existing nodes, and the index automatically populates as Neo4j processes the updates. Going forward, the ingestion code must use embedding instead of embedding_vector.

# Option A: Add the 'embedding' property to all existing nodes (keeps old property)
# Run in Neo4j Browser or via the driver

MATCH (n:Document)
WHERE n.embedding IS NULL AND n.embedding_vector IS NOT NULL
SET n.embedding = n.embedding_vector;
// Adds 'embedding' property to all 500 nodes
// Neo4j detects the property change and populates the vector index
// populationPercent will reach 100.0 after background indexing completes

# Option B: Drop the index and recreate on 'embedding_vector'
# Preferred if you want to keep 'embedding_vector' as the canonical property name

DROP INDEX document_embeddings IF EXISTS;

CREATE VECTOR INDEX document_embeddings IF NOT EXISTS
FOR (n:Document)
ON (n.embedding_vector)          -- ← index now watches the correct property
OPTIONS {
    indexConfig: {
        `vector.dimensions`: 1536,
        `vector.similarity_function`: 'cosine'
    }
};
# graph_store.py — FIXED
# neo4j Python driver 5.18.x, Python 3.11, Neo4j AuraDB 5.20

import time
from neo4j import GraphDatabase

URI    = "neo4j+s://xxxxxxxx.databases.neo4j.io"
AUTH   = ("neo4j", "...")
DRIVER = GraphDatabase.driver(URI, auth=AUTH)

def insert_documents(docs: list[dict]) -> int:
    with DRIVER.session(database="neo4j") as session:
        result = session.run(
            """
            UNWIND $docs AS doc
            MERGE (n:Document {doc_id: doc.doc_id})
            SET n.title     = doc.title,
                n.content   = doc.content,
                n.embedding = doc.embedding   -- ← FIXED: matches index property name
            RETURN count(n) AS nodes_created
            """,
            docs=docs
        )
        summary = result.single()
        return summary["nodes_created"]

def assert_vector_index_populated(index_name: str, min_pct: float = 1.0) -> None:
    """
    Asserts that the named vector index has indexed at least min_pct% of nodes.
    Blocks startup if index is empty (property name mismatch or ingestion not run).
    """
    with DRIVER.session(database="neo4j") as session:
        result = session.run(
            "SHOW INDEXES YIELD name, state, populationPercent "
            "WHERE name = $name",
            name=index_name,
        )
        row = result.single()
        if row is None:
            raise RuntimeError(f"Vector index '{index_name}' does not exist.")
        if row["state"] != "ONLINE":
            raise RuntimeError(
                f"Vector index '{index_name}' is in state '{row['state']}', expected ONLINE."
            )
        pct = row["populationPercent"]
        if pct < min_pct:
            raise RuntimeError(
                f"Vector index '{index_name}' is ONLINE but populationPercent={pct:.1f}% "
                f"(expected >= {min_pct}%). "
                "Check that the property name in your ingestion code matches the index definition. "
                "Run: MATCH (n:Document) RETURN keys(n) LIMIT 1 "
                "and compare to: SHOW INDEXES YIELD name, properties WHERE name = $name."
            )
        print(f"[OK] Vector index '{index_name}': ONLINE, populationPercent={pct:.1f}%")

def assert_queryNodes_returns_results(index_name: str, probe_vector: list[float]) -> None:
    """Sends a k=1 probe and asserts at least 1 result is returned."""
    with DRIVER.session(database="neo4j") as session:
        result = session.run(
            "CALL db.index.vector.queryNodes($index, 1, $vec) "
            "YIELD node, score RETURN count(node) AS n",
            index=index_name,
            vec=probe_vector,
        )
        count = result.single()["n"]
        if count == 0:
            raise RuntimeError(
                f"queryNodes('{index_name}', k=1) returned 0 results. "
                "Index may be empty — check populationPercent and property name alignment."
            )
        print(f"[OK] queryNodes('{index_name}', k=1) returned {count} result(s).")

# Call at application startup
assert_vector_index_populated("document_embeddings", min_pct=50.0)
assert_queryNodes_returns_results("document_embeddings", [0.0] * 1535 + [1.0])

Validation

# Validation log — October 31 2026
# neo4j Python driver 5.18.x, Python 3.11, Neo4j AuraDB 5.20

# ---- Before fix ----
# SHOW INDEXES: document_embeddings | ONLINE | populationPercent=0.0
# MATCH (n:Document) WHERE n.embedding IS NOT NULL RETURN count(n): 0
# MATCH (n:Document) WHERE n.embedding_vector IS NOT NULL RETURN count(n): 500
# db.index.vector.queryNodes('document_embeddings', 10, $vec): 0 results

# ---- After fix A: SET n.embedding = n.embedding_vector for all nodes ----
# SET run on 500 Document nodes
# Wait ~30s for background index population
# SHOW INDEXES: document_embeddings | ONLINE | populationPercent=100.0
# assert_vector_index_populated(): [OK] ONLINE, populationPercent=100.0%
# assert_queryNodes_returns_results(): [OK] queryNodes(k=1) returned 1 result
# db.index.vector.queryNodes('document_embeddings', 10, $vec): 10 results ✓

# ---- Hour breakdown ----
# Triage:     0.25h — SHOW INDEXES populationPercent=0.0; count nodes with 'embedding' property = 0
# Root cause: 0.25h — RETURN keys(n) confirms property is 'embedding_vector'; ingestion code mismatch
# Fix:        0.75h — SET n.embedding = n.embedding_vector; update ingestion code; add startup checks
# Validation: 0.25h — populationPercent=100%; queryNodes returns expected results; startup checks pass
# Total:      1.5h

# Retainer engagement cost: 1.5h (estimated $105-165 at mid-level rates)

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →