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.
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)(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.
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 nameParameters
| name | type |
|---|---|
| "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
| name | type | |---------|----------------------------------------| | "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.
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'}}(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:
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 DESCParameters
| name | score |
|---|---|
| "Grace" | 0.989 |
| "Noor" | 0.7151 |
2 rows
| name | score | |---------|--------| | "Grace" | 0.989 | | "Noor" | 0.7151 | 2 rows
Three rules shape the clause:
LIMITis required: it is the number of neighbors the index returns.- The score exists only under the name
SCORE ASgives it; there is no implicitscorevariable. - The variable after
SEARCHis the one theMATCHpattern 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.
Filter inside the search
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:
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 DESCParameters
| name | score |
|---|---|
| "Grace" | 0.989 |
| "Ada" | 0.6184 |
2 rows
| name | score | |---------|--------| | "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:
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| name | projects |
|---|---|
| "Noor" | ["relate"] |
1 row
| name | projects | |--------|------------| | "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:
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 sessionsParameters
| notes | sessions |
|---|---|
| 40 | 3 |
1 row · nodes created: 40, properties set: 200, labels added: 40, indexes added: 1
| notes | sessions | |-------|----------| | 40 | 3 | 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:
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, idParameters
| id | session | score | text |
|---|---|---|---|
| "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
| id | session | score | text | |-------|-------------|--------|---------------------------------------------------------------------------------| | "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:
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, idParameters
| id | session | score | text |
|---|---|---|---|
| "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
| id | session | score | text | |-------|-------------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | "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:
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 textParameters
| id | session | score | text |
|---|---|---|---|
| "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
| id | session | score | text | |-------|-------------|--------|---------------------------------------------------------------------------------| | "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:
| Rank | id | session | score | text |
|---|---|---|---|---|
| 1 | n02 | session-1 | 0.6252 | Customer login goes through Auth0; refresh tokens live in HttpOnly cookies. |
| 2 | n25 | session-2 | 0.5692 | Refresh tokens rotate on every use once Okta is live. |
| 3 | n03 | session-1 | 0.5585 | OAuth callbacks are published to Kafka so the audit service sees every login. |
| 4 | n15 | session-2 | 0.5536 | Migrate customer login from Auth0 to Okta before the April renewal. |
| 5 | n04 | session-1 | 0.5435 | Each 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.
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
knotes, all from session 3: the filter runs before the ranking and theLIMIT. - The
hash-384vectors 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 is2 - 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:
| Function | Range | Closer means | Examples |
|---|---|---|---|
vector.similarity.cosine(a, b) | 0 to 1 | higher | 1.0 same direction, 0.5 orthogonal, 0.0 opposite |
vector.similarity.euclidean(a, b) | greater than 0, up to 1: 1 / (1 + squared distance) | higher | 1.0 identical; [0, 0] and [3, 4] give 0.03846153989434242 |
vector_distance(a, b, <metric>) | the raw metric | lower | EUCLIDEAN for [0, 0] and [3, 4] gives 5.0 |
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| same | orthogonal | opposite | euclidean | distance |
|---|---|---|---|---|
| 1.0 | 0.5 | 0.0 | 0.03846153989434242 | 5.0 |
1 row
| same | orthogonal | opposite | euclidean | distance | |------|------------|----------|---------------------|----------| | 1.0 | 0.5 | 0.0 | 0.03846153989434242 | 5.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.
Combine with full-text search
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:
SHOW INDEXES YIELD name, type, labelsOrTypes, properties, state
WHERE type = 'VECTOR'
RETURN name, type, labelsOrTypes, properties, state| name | type | labelsOrTypes | properties | state |
|---|---|---|---|---|
| "note_embedding" | "VECTOR" | ["Note"] | ["embedding", "session"] | "ONLINE" |
| "person_focus" | "VECTOR" | ["Person"] | ["focus", "role"] | "ONLINE" |
2 rows
| name | type | labelsOrTypes | properties | state | |------------------|----------|---------------|--------------------------|----------| | "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:
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, scoreerror: type error: VectorIndexDimensionalityMismatch
| Message | Cause | Remedy |
|---|---|---|
type error: VectorIndexDimensionalityMismatch | The 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 defined | The 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. |
WrongPropertyForVectorSearchFilter | The 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 fails | Its 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. |
Related
- Search: index DDL, every
SEARCHform, 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.