Docs
View as markdown

Search by vector similarity

Store embeddings as typed vectors, index them, and find the nearest nodes with the SEARCH clause, with the score range of each similarity function.

cypher

Before you start: Your first graph in the CLI

This guide shows you how to store embeddings on node An entity in the graph: it carries zero or more labels and a map of properties, and relationships connect it to other nodes. Read more in the glossary, index them, and find the nearest nodes with the search clause The Cypher 25 SEARCH clause, which restricts a MATCH to the top results of a vector or full-text index and binds their score. Read more in the glossary. The cells run in page order on one in-memory store A store that lives only in the process that opened it, named :memory:; the default --database, gone when the process exits. Read more in the glossary, in your browser and in the docs build, so each step sees what the steps before it wrote.

Prerequisites

  • The example graph from your first graph: three people and the two projects they work on. The first step creates it, so this page runs on its own.
  • Embeddings from the model of your choice. This guide uses three-dimensional vectors so the numbers stay readable; a model's vectors have hundreds of dimensions, and every step works the same way.

Load the example graph

This cell writes the shared graph: Ada, Grace and Noor, the relate and opencypher-tck projects, and who works on what.

Cypher
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace)
Recorded result
(no rows) · nodes created: 5, relationships created: 5, properties set: 13, labels added: 5

Store the vectors

Give each person a focus vector: how much of their work goes to the engine, the tests, and the docs. Pass the vectors as a parameter A named value a statement refers to as $name and the caller binds separately, such as with --param name=value on the CLI. Read more in the glossary and store each one with vector(list, dimensions, FLOAT32). The typed vector is also a guard: a list of the wrong length fails the statement with type error: vector() dimension does not match list instead of being stored.

Cypher
UNWIND $people AS row
MATCH (p:Person {name: row.name})
SET p.focus = vector(row.focus, 3, FLOAT32)
RETURN p.name AS name, valueType(p.focus) AS type
ORDER BY name
Parameters
Recorded result
nametype
"Ada""VECTOR<FLOAT32 NOT NULL>(3) NOT NULL"
"Grace""VECTOR<FLOAT32 NOT NULL>(3) NOT NULL"
"Noor""VECTOR<FLOAT32 NOT NULL>(3) NOT NULL"

3 rows · properties set: 3

The coordinate type is a bare keyword, such as FLOAT32, FLOAT64, INT8 or INT64; the search reference lists every accepted spelling.

Create a vector index

Create the index on the property, with the vector length and the similarity function in OPTIONS. WITH [p.role] declares a property A named value stored on a node or relationship, such as name: 'Ada'; values are scalars, temporal and spatial values, vectors, or lists of them. Read more in the glossary that a search can filter on before it ranks.

Cypher
CREATE VECTOR INDEX person_focus IF NOT EXISTS
FOR (p:Person) ON (p.focus) WITH [p.role]
OPTIONS {indexConfig: {`vector.dimensions`: 3, `vector.similarity_function`: 'cosine'}}
Recorded result
(no rows) · indexes added: 1

The indexConfig keys contain dots, so quote them in backticks or single quotes; an unquoted vector.dimensions is a parse error. The similarity function is 'cosine' (the default) or 'euclidean'. The search reference lists every option and its default.

Find the nearest nodes

The question vector leans hard on tests. SEARCH binds p to the nearest people and SCORE AS names the similarity:

Cypher
MATCH (p:Person)
SEARCH p IN (VECTOR INDEX person_focus FOR $question LIMIT 2) SCORE AS score
RETURN p.name AS name, round(score, 4) AS score
ORDER BY score DESC
Parameters
Recorded result
namescore
"Grace"0.989
"Noor"0.7151

2 rows

Three rules shape the clause:

  • LIMIT is required: it is the number of neighbors the index returns.
  • The score exists only under the name SCORE AS gives it; there is no implicit score variable.
  • The variable after SEARCH is the one the MATCH pattern declares, and the query vector cannot refer to it.

A higher score means more similar. The search uses the index's similarity function, so these scores are the ones vector.similarity.cosine returns.

Put the filter inside the parentheses. It runs before the ranking and the LIMIT, so the search still returns two people when one of the nearest neighbors does not match:

Cypher
MATCH (p:Person)
SEARCH p IN (VECTOR INDEX person_focus FOR $question WHERE p.role = 'engineer' LIMIT 2) SCORE AS score
RETURN p.name AS name, round(score, 4) AS score
ORDER BY score DESC
Parameters
Recorded result
namescore
"Grace"0.989
"Ada"0.6184

2 rows

Noor, the second nearest, is a maintainer, so Ada takes the second place. The filter may name only the properties in the index's WITH [...] list, compared with simple comparisons or IN a list, joined by AND. A WHERE after the SEARCH clause filters only the rows the search already returned, so it can leave fewer than LIMIT.

The result is an ordinary match, so the next clause can walk the graph from it:

Cypher
MATCH (p:Person)
SEARCH p IN (VECTOR INDEX person_focus FOR [0.2, 0.2, 0.9] LIMIT 1) SCORE AS score
MATCH (p)-[:WORKS_ON|MAINTAINS]->(proj:Project)
RETURN p.name AS name, collect(proj.name) AS projects
Recorded result
nameprojects
"Noor"["relate"]

1 row

See the search on 40 real vectors

Three readable vectors hide what a search does among hundreds of dimensions. The 40 notes in this cell come from the hybrid recall recipe, with the same text and vectors: notes from three working sessions of one project, each with the 384-dimension vector that recipe's hash-384 embedder computed. That embedder hashes lowercased words and character trigrams, so it measures shared words rather than meaning, which is enough to see the mechanics. The cell stores each note with its session and its first topic, and creates a cosine index that can filter on the session:

Cypher
UNWIND $notes AS r
CREATE (:Note {id: r.id, text: r.text, session: r.session, topic: r.topics[0],
               embedding: vector(r.e, 384, FLOAT32)});
CREATE VECTOR INDEX note_embedding IF NOT EXISTS
FOR (n:Note) ON (n.embedding) WITH [n.session]
OPTIONS {indexConfig: {`vector.dimensions`: 384, `vector.similarity_function`: 'cosine'}};
MATCH (n:Note)
RETURN count(n) AS notes, count(DISTINCT n.session) AS sessions
Parameters
Recorded result
notessessions
403

1 row · nodes created: 40, properties set: 200, labels added: 40, indexes added: 1

The question is the recipe's first, embedded the same way. $k is the number of notes the search returns:

Cypher
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_embedding FOR $v LIMIT $k) SCORE AS score
RETURN n.id AS id, n.session AS session, round(score, 4) AS score, n.text AS text
ORDER BY score DESC, id
Parameters
Recorded result
idsessionscoretext
"n02""session-1"0.6252"Customer login goes through Auth0; refresh tokens live in HttpOnly cookies."
"n25""session-2"0.5692"Refresh tokens rotate on every use once Okta is live."
"n03""session-1"0.5585"OAuth callbacks are published to Kafka so the audit service sees every login."
"n15""session-2"0.5536"Migrate customer login from Auth0 to Okta before the April renewal."
"n04""session-1"0.5435"Each tenant gets its own Kafka topic for ticket events."

5 rows

The same search with a filter inside it keeps the notes of $session before it ranks:

Cypher
MATCH (n:Note)
SEARCH n IN (VECTOR INDEX note_embedding FOR $v WHERE n.session = $session LIMIT $k) SCORE AS score
RETURN n.id AS id, n.session AS session, round(score, 4) AS score, n.text AS text
ORDER BY score DESC, id
Parameters
Recorded result
idsessionscoretext
"n31""session-3"0.5386"Magic-link login is requested by three enterprise customers."
"n35""session-3"0.5383"Tempo retention is fourteen days; older traces are sampled out."
"n29""session-3"0.5287"Okta cutover is done; Auth0 stays read-only for thirty days."
"n26""session-3"0.5279"Incident KAFKA-142: a broker disk filled overnight because retention on the audit stream was unbounded; retention is now capped and a disk alert pages the on-call engineer."
"n34""session-3"0.5271"Deploy freeze for the billing launch starts on the 23rd."

5 rows

An index ranks with the similarity function it was created with. To rank with the other one without a second index, order by the function; this statement reads every note instead of using the index:

Cypher
MATCH (n:Note)
WITH n, vector.similarity.euclidean(n.embedding, $v) AS score
ORDER BY score DESC, n.id LIMIT $k
RETURN n.id AS id, n.session AS session, round(score, 4) AS score, n.text AS text
Parameters
Recorded result
idsessionscoretext
"n02""session-1"0.4001"Customer login goes through Auth0; refresh tokens live in HttpOnly cookies."
"n25""session-2"0.3672"Refresh tokens rotate on every use once Okta is live."
"n03""session-1"0.3615"OAuth callbacks are published to Kafka so the audit service sees every login."
"n15""session-2"0.359"Migrate customer login from Auth0 to Okta before the April renewal."
"n04""session-1"0.3539"Each tenant gets its own Kafka topic for ticket events."

5 rows

The explorer runs these three statements on this page's database. Each point is a note, placed by the first two principal components of the 40 vectors and coloured by topic; the cross is the question.

The top 5 by recall for "what did we decide about oauth tokens", as recorded with k = 5:

Rankidsessionscoretext
1n02session-10.6252Customer login goes through Auth0; refresh tokens live in HttpOnly cookies.
2n25session-20.5692Refresh tokens rotate on every use once Okta is live.
3n03session-10.5585OAuth callbacks are published to Kafka so the audit service sees every login.
4n15session-20.5536Migrate customer login from Auth0 to Okta before the April renewal.
5n04session-10.5435Each tenant gets its own Kafka topic for ticket events.

The picture places the 40 vectors of the notes block (384 dimensions) on their first two principal components, which hold 7.2% and 6.4% of the variance, computed when the page was recorded. The cross is the question vector v, and the rings mark the top 5, numbered by rank.

The 40 vectors of notes on two principal components, with the question and its top 540 points on the first two principal components. The question sits at (-0.046, 0.140). Top 5: n02, n25, n03, n15, n04. Colour shows topics: auth, billing, deploy, kafka, observability, planning.PC1 · 7.2% of variancePC2 · 6.4%n01 (planning): Relay kickoff: ticket intake, the agent console and billing all live in one monorepo.n02 (auth): Customer login goes through Auth0; refresh tokens live in HttpOnly cookies.n03 (auth): OAuth callbacks are published to Kafka so the audit service sees every login.n04 (kafka): Each tenant gets its own Kafka topic for ticket events.n05 (deploy): Deploy with Helm charts to the shared staging cluster.n06 (auth): Open question: do support agents and customers share one login page?n07 (kafka): Kafka consumer lag on the ticket-events group is the first alert to build.n08 (deploy): Staging deploys take eleven minutes; most of it is image pulls.n09 (billing): Billing needs per-seat usage counts from the agent console.n10 (auth): Session cookies expire after twelve hours of inactivity.n11 (observability): Logs from every service are shipped to Loki for now.n12 (planning): Ticket search indexes subject lines only; bodies come later.n13 (auth): Load test plan: 200 logins per second against staging.n14 (auth): The Auth0 renewal quote doubled; Okta is cheaper for our seat count.n15 (auth): Migrate customer login from Auth0 to Okta before the April renewal.n16 (auth): Token revocation now propagates in under a second via the Redis session store.n17 (kafka): Per-tenant topics hit the Kafka partition limit at 900 tenants.n18 (kafka): Consolidate ticket events into shared Kafka topics keyed by tenant id.n19 (kafka): Kafka consumers must be idempotent because shared topics replay on rebalance.n20 (observability): Traces go to Tempo through the OpenTelemetry collector; Loki keeps the logs.n21 (deploy): The deploy pipeline caches base images; staging deploys now take four minutes.n22 (auth): The Okta tenant needs a custom domain before the login page can move.n23 (kafka): Kafka lag dashboard added to Grafana with a five minute alert.n24 (billing): Billing reads seat counts from the nightly usage snapshot, not from Kafka.n25 (auth): Refresh tokens rotate on every use once Okta is live.n26 (kafka): Incident KAFKA-142: a broker disk filled overnight because retention on the audit stream was unbounded; retention is now capped and a disk alert pages the on-call engineer.n27 (kafka): Retention on shared Kafka topics is three days.n28 (deploy): Canary releases go through Argo Rollouts with a five percent first step.n29 (auth): Okta cutover is done; Auth0 stays read-only for thirty days.n30 (billing): Invoices are generated nightly from Kafka usage events.n31 (auth): Magic-link login is requested by three enterprise customers.n32 (kafka): Kafka status: consumer lag is back under one second after the rebalance fix.n33 (kafka): The Kafka status page is green again; broker CPU is flat.n34 (deploy): Deploy freeze for the billing launch starts on the 23rd.n35 (observability): Tempo retention is fourteen days; older traces are sampled out.n36 (auth): Rotating signing keys for Okta sessions is a quarterly job.n37 (billing): Billing webhooks retry with exponential backoff up to one hour.n38 (deploy): Rollback drill: Argo Rollouts aborted the canary in ninety seconds.n39 (auth): Support agents keep a separate SSO login through the corporate Okta.n40 (kafka): Kafka upgrade: brokers are on 3.7 and new clients ship next week.13542questionauthbillingdeploykafkaobservabilityplanningtop 5question
The 40 vectors of notes on two principal components, with the question and its top 540 points on the first two principal components. The question sits at (-0.046, 0.140). Top 5: n02, n25, n03, n15, n04. Colour shows topics: auth, billing, deploy, kafka, observability, planning.PC1 · 7.2% of variancePC2 · 6.4%n01 (planning): Relay kickoff: ticket intake, the agent console and billing all live in one monorepo.n02 (auth): Customer login goes through Auth0; refresh tokens live in HttpOnly cookies.n03 (auth): OAuth callbacks are published to Kafka so the audit service sees every login.n04 (kafka): Each tenant gets its own Kafka topic for ticket events.n05 (deploy): Deploy with Helm charts to the shared staging cluster.n06 (auth): Open question: do support agents and customers share one login page?n07 (kafka): Kafka consumer lag on the ticket-events group is the first alert to build.n08 (deploy): Staging deploys take eleven minutes; most of it is image pulls.n09 (billing): Billing needs per-seat usage counts from the agent console.n10 (auth): Session cookies expire after twelve hours of inactivity.n11 (observability): Logs from every service are shipped to Loki for now.n12 (planning): Ticket search indexes subject lines only; bodies come later.n13 (auth): Load test plan: 200 logins per second against staging.n14 (auth): The Auth0 renewal quote doubled; Okta is cheaper for our seat count.n15 (auth): Migrate customer login from Auth0 to Okta before the April renewal.n16 (auth): Token revocation now propagates in under a second via the Redis session store.n17 (kafka): Per-tenant topics hit the Kafka partition limit at 900 tenants.n18 (kafka): Consolidate ticket events into shared Kafka topics keyed by tenant id.n19 (kafka): Kafka consumers must be idempotent because shared topics replay on rebalance.n20 (observability): Traces go to Tempo through the OpenTelemetry collector; Loki keeps the logs.n21 (deploy): The deploy pipeline caches base images; staging deploys now take four minutes.n22 (auth): The Okta tenant needs a custom domain before the login page can move.n23 (kafka): Kafka lag dashboard added to Grafana with a five minute alert.n24 (billing): Billing reads seat counts from the nightly usage snapshot, not from Kafka.n25 (auth): Refresh tokens rotate on every use once Okta is live.n26 (kafka): Incident KAFKA-142: a broker disk filled overnight because retention on the audit stream was unbounded; retention is now capped and a disk alert pages the on-call engineer.n27 (kafka): Retention on shared Kafka topics is three days.n28 (deploy): Canary releases go through Argo Rollouts with a five percent first step.n29 (auth): Okta cutover is done; Auth0 stays read-only for thirty days.n30 (billing): Invoices are generated nightly from Kafka usage events.n31 (auth): Magic-link login is requested by three enterprise customers.n32 (kafka): Kafka status: consumer lag is back under one second after the rebalance fix.n33 (kafka): The Kafka status page is green again; broker CPU is flat.n34 (deploy): Deploy freeze for the billing launch starts on the 23rd.n35 (observability): Tempo retention is fourteen days; older traces are sampled out.n36 (auth): Rotating signing keys for Okta sessions is a quarterly job.n37 (billing): Billing webhooks retry with exponential backoff up to one hour.n38 (deploy): Rollback drill: Argo Rollouts aborted the canary in ninety seconds.n39 (auth): Support agents keep a separate SSO login through the corporate Okta.n40 (kafka): Kafka upgrade: brokers are on 3.7 and new clients ship next week.13542questionauthbillingdeploykafkaobservabilityplanningtop 5question
The 40 vectors of notes on two principal components, with the question and its top 5

Live: a k slider runs the page's search statements on its database in your browser with that k, and the view marks the ids the engine returns.

Three things to notice:

  • The picture flattens 384 dimensions into two, so a ringed note can sit farther from the cross than one the search skipped. The engine ranks in all 384 dimensions; the picture only shows which notes it returned.
  • With the pre-filter on, the notes outside session 3 grey out and the search still returns k notes, all from session 3: the filter runs before the ranking and the LIMIT.
  • The hash-384 vectors have length 1, so cosine and euclidean rank the notes in the same order and only the scores differ. For unit vectors the squared distance is 2 - 2 * cosine, and both scores fall as it grows.

A question can only be searched against vectors from the model that embeds it, so a sentence of your own cannot join these notes. The Query the relate docs recipe searches docs sections embedded with the bge-small-en-v1.5 model, and in a browser it can download that model, embed a question you type, and search with it.

Read the score

The two similarity functions return a rebounded score, not a raw cosine or distance. Thresholds belong on these scales:

FunctionRangeCloser meansExamples
vector.similarity.cosine(a, b)0 to 1higher1.0 same direction, 0.5 orthogonal, 0.0 opposite
vector.similarity.euclidean(a, b)greater than 0, up to 1: 1 / (1 + squared distance)higher1.0 identical; [0, 0] and [3, 4] give 0.03846153989434242
vector_distance(a, b, <metric>)the raw metriclowerEUCLIDEAN for [0, 0] and [3, 4] gives 5.0
Cypher
RETURN vector.similarity.cosine([1, 0], [1, 0]) AS same,
       vector.similarity.cosine([1, 0], [0, 1]) AS orthogonal,
       vector.similarity.cosine([1, 0], [-1, 0]) AS opposite,
       vector.similarity.euclidean([0, 0], [3, 4]) AS euclidean,
       vector_distance(vector([0, 0], 2, FLOAT32), vector([3, 4], 2, FLOAT32), EUCLIDEAN) AS distance
Recorded result
sameorthogonaloppositeeuclideandistance
1.00.50.00.038461539894342425.0

1 row

The similarity functions take a list or a typed vector; vector_distance takes typed vectors only. The search reference lists the metrics.

A vector search finds what is near in meaning; a full-text search finds the exact words. To use both, chain one MATCH … SEARCH per index through WITH, so the second search keeps only what the first found, or fuse the two ranked lists by reciprocal rank. Hybrid recall builds the fused version over 40 notes in one statement, and the search reference has both forms.

Verify

SHOW INDEXES lists the index, the properties it covers (the vector property, then the WITH properties) and its state:

Cypher
SHOW INDEXES YIELD name, type, labelsOrTypes, properties, state
WHERE type = 'VECTOR'
RETURN name, type, labelsOrTypes, properties, state
Recorded result
nametypelabelsOrTypespropertiesstate
"note_embedding""VECTOR"["Note"]["embedding", "session"]"ONLINE"
"person_focus""VECTOR"["Person"]["focus", "role"]"ONLINE"

2 rows

Two ONLINE indexes, person_focus and note_embedding, and two rows from the nearest-node search mean the index serves the query.

If it goes wrong

A query vector whose length differs from vector.dimensions fails:

Cypher
MATCH (p:Person)
SEARCH p IN (VECTOR INDEX person_focus FOR [0.1, 0.9] LIMIT 2) SCORE AS score
RETURN p.name AS name, score
Expected error
error: type error: VectorIndexDimensionalityMismatch
MessageCauseRemedy
type error: VectorIndexDimensionalityMismatchThe query vector's length is not the index's vector.dimensions.Embed the question with the same model as the stored vectors.
syntax error: UndefinedVariable: variable 'score' not definedThe query reads score without SCORE AS.Add SCORE AS score after the closing parenthesis.
syntax error: parse error: expected LIMIT, got ")" (RightParen)The search has no LIMIT.Add LIMIT <k> inside the parentheses.
WrongPropertyForVectorSearchFilterThe WHERE inside the search names a property the index does not declare.Add the property to the index's WITH [...] list, or filter after the search.
A node never comes back, and nothing failsIts property holds a list or vector of another length: the index skips it without an error.Store vectors with vector(list, <dimensions>, FLOAT32) at the index's dimensions; it refuses a list of another length at write time.
  • Search: index DDL, every SEARCH form, options and defaults, and the functions.
  • Hybrid recall: vector and full-text search fused in one statement.
  • Differences from Neo4j: the procedure forms and what to write instead.