Docs
View as markdown

Search

Vector and full-text index DDL, the SEARCH clause and its rules, index options and defaults, the search procedures and vector functions, and hybrid search.

cypher

relate searches two kinds of index A structure the planner uses to find nodes or relationships by a property without scanning a label: range, text, point, vector or full-text. Read more in the glossary. A vector index An index over a property holding embeddings that answers nearest-neighbor searches exactly, by cosine or Euclidean similarity. Read more in the glossary answers "which entities are nearest to this vector" with an exact nearest-neighbor search: every search scores each indexed vector and keeps the top LIMIT, ties going to the lower element id; a full-text index An index over one or more string properties that finds entities containing given words, ranked by a BM25 relevance score. Read more in the glossary answers "which entities contain these words". The SEARCH clause restricts a MATCH to an index's top results and names their score. The cells on this page run on relate's WebAssembly (WASM) build, the weighted fusion included; the COMBINE example runs on the CLI, because it needs a preview opt-in.

Synopsis

CREATE VECTOR INDEX <name> [IF NOT EXISTS]
  FOR (<var>:<Label>) ON (<var>.<property>)
  [WITH [<var>.<property>, …]]
  [OPTIONS {indexConfig: {<key>: <value>, …}}]

CREATE VECTOR INDEX <name> [IF NOT EXISTS]
  FOR ()-[<var>:<TYPE>]-() ON (<var>.<property>)
  [WITH [<var>.<property>, …]]
  [OPTIONS {indexConfig: {<key>: <value>, …}}]

CREATE FULLTEXT INDEX <name> [IF NOT EXISTS]
  FOR (<var>:<Label>) ON EACH [<var>.<property>, …]

CREATE FULLTEXT INDEX <name> [IF NOT EXISTS]
  FOR ()-[<var>:<TYPE>]-() ON EACH [<var>.<property>, …]

DROP INDEX <name> [IF EXISTS]

MATCH <pattern that binds var>
SEARCH <var> IN (
  VECTOR INDEX <name> FOR <query vector> [WHERE <filter>] LIMIT <k>
) [SCORE AS <score var>]

MATCH <pattern that binds var>
SEARCH <var> IN (
  FULLTEXT INDEX <name> FOR <query string> [WITH ANALYZER <expr>] [SKIP <n> | OFFSET <n>] LIMIT <k>
) [SCORE AS <score var>]

Index statements

StatementResultNotes
CREATE VECTOR INDEXAn index over one property holding vectorsWITH [...] declares the properties a vector search may filter on; SHOW INDEXES lists them after the vector property
CREATE FULLTEXT INDEXAn index over one or more string propertiesON EACH [...] lists the properties
IF NOT EXISTSNo error when an index of that name existsWithout it: semantic error: IndexAlreadyExists
DROP INDEX <name>Removes the indexThe CLI prints nothing on success; a missing name fails with semantic error: NoSuchIndex: `<name>` , and IF EXISTS turns that into a no-op
SHOW INDEXESOne row per indexColumns include name, type (VECTOR, FULLTEXT, RANGE), labelsOrTypes, properties, state and options

A second vector index on the same label and property with the same WITH [...] list, the same properties in the same order, fails with semantic error: EquivalentSchemaRuleAlreadyExists, whatever its OPTIONS. One with a different WITH [...] list, or none where the first has one, is created beside it. A second full-text index on the same label and properties fails with the same error.

Vector index options

The keys go in OPTIONS {indexConfig: {…}}. They contain dots, so each key is quoted in backticks or single quotes; an unquoted key is a parse error. The defaults are the values SHOW INDEXES reports for an index created without options.

KeyDefaultAccepted values
vector.dimensionsnot setAn integer from 1 to 4096; 0 and 4097 fail with semantic error: NumberOutOfRange
vector.similarity_functionCOSINE'cosine' or 'euclidean', in any case; other values fail with semantic error: InvalidInputValue
vector.hnsw.m16An integer; SHOW INDEXES reports the value set; stored and reported only, the search does not use it
vector.hnsw.ef_construction100An integer; SHOW INDEXES reports the value set; stored and reported only, the search does not use it
vector.default_search_expansion_factor3.0A number; SHOW INDEXES reports the value set
vector.quantization.typeBINARYA string; SHOW INDEXES reports the value set

An unknown key fails with semantic error: InvalidInputValue. A property whose vector length differs from vector.dimensions is stored without an error and is never returned by a search. A typed vector, vector(<list>, <dimensions>, FLOAT32), fails the write when the list has another length.

Rules of the search clause

RuleVector searchFull-text searchError when broken
LIMITRequiredRequiredsyntax error: parse error: expected LIMIT, got ")" (RightParen)
LIMIT valueA non-negative integer; 0 returns no rowsSamesyntax error: NumberOutOfRange: SEARCH LIMIT must be an INTEGER NOT NULL
ScoreOnly through SCORE AS <var>; higher is more similarOnly through SCORE AS <var>; a BM25 relevance score, higher is more relevantsyntax error: UndefinedVariable: variable 'score' not defined
WHERE inside the searchAllowed, on the index's WITH [...] properties only, before ranking and LIMITNot allowedWrongPropertyForVectorSearchFilter; syntax error: InvalidUseOfCommand: WHERE cannot be used together with 'FULLTEXT INDEX'
Filter predicatesEquality, IN a list, a half-bounded range or a bounded range per property, joined by AND; IN cannot be combined with another predicate on the same property—syntax error: SyntaxError: InvalidSearchFilterPredicate: invalid predicate for vector search filtering. … (for OR, <>, or IN with a second predicate on its property)
SKIP or OFFSETNot allowedAllowedsyntax error: InvalidUseOfCommand: SKIP cannot be used together with VECTOR INDEX
WITH ANALYZERNot allowedAccepted: names the analyzer of the query string (Full-text matching)syntax error: InvalidUseOfCommand: WITH ANALYZER cannot be used together with VECTOR INDEX
Query valueA list or vector of vector.dimensions numbersA stringtype error: VectorIndexDimensionalityMismatch
Search variableThe variable after SEARCH, declared by the MATCH pattern; the query cannot refer to itSamesyntax error: SelfReferencingSearch: the search query cannot reference the binding variable '<var>'
Index kindA vector indexA full-text indexWrongIndexType: <name>
Index existsRequiredRequiredIndexDoesNotExist: <name>
MATCH shapeOne path pattern: one node, or one relationship hop, with one variableSameSeveral patterns: syntax error: SyntaxError: SearchClauseTooComplexPattern: a SEARCH clause's MATCH statement must be a single path pattern. More than one hop: syntax error: SyntaxError: SearchClauseTooComplexPattern: a SEARCH clause's MATCH statement must be a single node or single-hop relationship pattern. More than one variable: syntax error: SyntaxError: SearchClauseMultipleBoundVariables: a SEARCH clause's MATCH statement may bind only one variable
Searches per MATCHOneOnesyntax error: parse error: unexpected token "SEARCH"

RELATE_PREVIEW=complex-search-patterns lifts the MATCH shape rule. A WHERE after the SEARCH clause, or a property map in the MATCH pattern, filters only the rows the search returned, so the result can hold fewer than LIMIT rows.

Full-text matching

BehaviorValue
TermsLowercase alphanumeric words; matching ignores case. Accented letters are not folded: cafe does not match café
Stemming and stop wordsNone by default: graphs does not match graph, and the is a term
Several words in the queryEach word is a term, and a match on any term returns the entity
OperatorsAND, OR and NOT in capitals, and && and ||, combine terms; parentheses group them. A lowercase and is a term. A query of NOT clauses alone matches nothing
One property<property>:<term> matches the term in that indexed property only
Phrases, wildcards, fuzzy terms, boostsNot read: the words of a quoted phrase match as separate terms, tor* and tset~ match nothing, and +, -, ! and ^<n> change no result
ScoreBM25, summed over each matching property and term: a term that few entities hold counts for more, and a match in a short text counts for more than one in a long text. Calibrated against Neo4j's full-text scores; not normalized, so it ranks results within one search only
Order of equal scoresNo documented order; the examples on this page sort on a second key
WITH ANALYZER <name>The analyzer of the query string. english drops English stop words and stems the query's words, so graphs searches for graph; every other analyzer name splits the query like the default. null keeps the default. An unknown name fails with semantic error: Invalid input '<name>' for analyzer name. Expected to be arabic, armenian, …
The index's fulltext.analyzer optionAccepted and listed by SHOW INDEXES; the indexed text is always split by the default analyzer, so the stored words keep their written form

Procedures

Each procedure is deprecated: EXPLAIN reports notification 01N01, The procedure `<name>` is deprecated., with the remedy Use the `SEARCH` clause instead.

ProcedureYieldsNotes
db.index.vector.queryNodes(<index>, <k>, <vector>)node, scoreThe top k nodes; no filter
db.index.vector.queryRelationships(<index>, <k>, <vector>)relationship, scoreThe top k relationships; no filter
db.index.fulltext.queryNodes(<index>, <query>)node, scoreThe query syntax and the scores of a full-text SEARCH
db.index.fulltext.queryRelationships(<index>, <query>)relationship, scoreThe query syntax and the scores of a full-text SEARCH

Functions

FunctionReturnsNotes
vector(<list>, <dimensions>, <type>)A typed vector, VECTOR<FLOAT32 NOT NULL>(3) NOT NULL for three FLOAT32 coordinates<type> is a bare keyword: FLOAT32; FLOAT or FLOAT64; INT8 or INTEGER8; INT16 or INTEGER16; INT32 or INTEGER32; INT, INTEGER, INT64, INTEGER64 or SIGNED INTEGER; and, behind RELATE_PREVIEW=float16-coordinate-types, FLOAT16 and BFLOAT16; a list of another length fails with type error: vector() dimension does not match list
vector.similarity.cosine(<a>, <b>)0 to 1, higher is closer: 1.0 same direction, 0.5 orthogonal, 0.0 oppositeLists or vectors of equal length
vector.similarity.euclidean(<a>, <b>)Greater than 0, up to 1, higher is closer: 1 / (1 + squared distance)Lists or vectors of equal length
vector_distance(<a>, <b>, <metric>)The raw metric, lower is closerTyped vectors only; <metric> is EUCLIDEAN, EUCLIDEAN_SQUARED, MANHATTAN, COSINE, DOT or HAMMING; DOT is the negated dot product
vector_norm(<v>, <metric>)The distance from the originTyped vector; EUCLIDEAN or MANHATTAN
vector_dimension_count(<v>)The number of coordinatesTyped vector
InputResult
null for a whole argumentnull
Two lengthstype error: InvalidArgumentValue: vectors must have same dimensions
A list passed to vector_distancetype error: InvalidArgumentType: expected a VECTOR
An all-zero vector to vector.similarity.cosinetype error: InvalidArgumentValue: not a valid vector

Three ways combine a vector search and a full-text search in one statement:

FormResultStatus
MATCH … SEARCH (vector), WITH, then MATCH … SEARCH (full-text) on the same variableThe intersection: the second search keeps only entities the first returnedStable
CALL () { <vector search> UNION ALL <full-text search> }, each branch numbering its rows by rank, then sum(<w> / (<c> + rank + 1)) per entityOne ranking fused by weighted reciprocal rank, written in CypherStable
SEARCH <var> IN (COMBINE (<source> WEIGHT <w>, <source> WEIGHT <w>, …) USING WRRF [WITH CONSTANT <c>] LIMIT <k>)One ranking fused by weighted reciprocal rankPreview, behind RELATE_PREVIEW=hybrid-search

COMBINE takes two or more sources, each a complete index specification with its own LIMIT, and the same entity kind as the variable after SEARCH. An entity's fused score is the sum over the sources of weight / (constant + rank + 1), where rank counts from 0 within that source and a source that did not return the entity adds nothing. WEIGHT defaults to 1 and WITH CONSTANT to 60. Equal fused scores order by entity id. Without the opt-in, COMBINE fails with semantic error: SemanticError: preview feature 'hybrid-search' is not enabled: set RELATE_PREVIEW=hybrid-search.

The CALL form computes the same scores without the opt-in: the weighted-fusion cell in Examples returns, digit for digit, the scores the COMBINE run there prints for the same weights and constant.

Examples

The shared example graph, with a focus vector and a short bio on each person:

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);
MATCH (p:Person)
SET p.focus = CASE p.name
      WHEN 'Ada' THEN vector([0.9, 0.1, 0.2], 3, FLOAT32)
      WHEN 'Grace' THEN vector([0.3, 0.9, 0.1], 3, FLOAT32)
      ELSE vector([0.2, 0.3, 0.9], 3, FLOAT32) END,
    p.bio = CASE p.name
      WHEN 'Ada' THEN 'Writes the storage engine in Rust'
      WHEN 'Grace' THEN 'Keeps the conformance tests green'
      ELSE 'Reviews the docs and the tests' END
Recorded result
(no rows) · nodes created: 5, relationships created: 5, properties set: 19, labels added: 5

One index of each kind:

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'}};
CREATE FULLTEXT INDEX person_bio IF NOT EXISTS FOR (p:Person) ON EACH [p.bio]
Recorded result
(no rows) · indexes added: 2

A vector search, filtered inside the search:

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

2 rows

A full-text search for two words; Noor's bio holds both:

Cypher
MATCH (p:Person)
SEARCH p IN (FULLTEXT INDEX person_bio FOR 'docs tests' LIMIT 3) SCORE AS score
RETURN p.name AS name, score
ORDER BY score DESC, name
Recorded result
namescore
"Noor"0.6439728198009622
"Grace"0.22443993531397488

2 rows

The intersection form of hybrid search: the nearest two by vector, kept only if their bio mentions the docs:

Cypher
MATCH (p:Person)
SEARCH p IN (VECTOR INDEX person_focus FOR [0.1, 0.9, 0.1] LIMIT 2) SCORE AS vs
WITH p, vs
MATCH (p)
SEARCH p IN (FULLTEXT INDEX person_bio FOR 'docs' LIMIT 3) SCORE AS fs
RETURN p.name AS name, round(vs, 4) AS vector, fs AS fulltext
ORDER BY name
Recorded result
namevectorfulltext
"Noor"0.71510.43535502091904305

1 row

Weighted reciprocal rank fusion, the CALL form. The question, its vector and the number of results per list are parameters:

Each search on its own returns a ranked list of at most $k people, with the name as id:

Cypher
MATCH (p:Person)
SEARCH p IN (VECTOR INDEX person_focus FOR $v LIMIT $k) SCORE AS score
RETURN p.name AS id, score
ORDER BY score DESC, id
Parameters
Recorded result
idscore
"Grace"0.9890228509902954
"Noor"0.7151050567626953
"Ada"0.6183618307113647

3 rows

Cypher
MATCH (p:Person)
SEARCH p IN (FULLTEXT INDEX person_bio FOR $q LIMIT $k) SCORE AS score
RETURN p.name AS id, score
ORDER BY score DESC, id
Parameters
Recorded result
idscore
"Noor"0.6439728198009622
"Grace"0.22443993531397488

2 rows

The fused statement runs both searches, numbers each list's rows from 0, and adds weight / (60 + rank + 1) per person, with weight 0.7 for the vector list and 0.3 for the full-text list:

Cypher
CALL () {
  MATCH (p:Person)
  SEARCH p IN (VECTOR INDEX person_focus FOR $v LIMIT $k) SCORE AS score
  WITH p ORDER BY score DESC, p.name
  WITH collect(p) AS ps
  UNWIND range(0, size(ps) - 1) AS rank
  RETURN ps[rank] AS p, 0.7 AS weight, rank
  UNION ALL
  MATCH (p:Person)
  SEARCH p IN (FULLTEXT INDEX person_bio FOR $q LIMIT $k) SCORE AS score
  WITH p ORDER BY score DESC, p.name
  WITH collect(p) AS ps
  UNWIND range(0, size(ps) - 1) AS rank
  RETURN ps[rank] AS p, 0.3 AS weight, rank
}
WITH p, sum(weight / (60 + rank + 1)) AS rrf
RETURN p.name AS id, rrf
ORDER BY rrf DESC, id
LIMIT $k
Parameters
Recorded result
idrrf
"Grace"0.01631411951348493
"Noor"0.016208355367530406
"Ada"0.01111111111111111

3 rows

The view runs the two lists and the fused statement with the chosen k, up to the three people the graph holds, and writes out each fused score's arithmetic beside the statement's own:

The 2 ranked lists for "docs tests", as recorded with k = 3:

Rankvector-listfulltext-list
1GraceNoor
2NoorGrace
3Ada

Fused by reciprocal rank: each list gives an id its weight / (60 + its rank there), and the shares add up. 3 ids, best first:

idRank in vector-listRank in fulltext-listArithmeticFused score
Grace120.7/(60+1) + 0.3/(60+2)0.0163
Noor210.7/(60+2) + 0.3/(60+1)0.0162
Ada30.7/(60+3)0.0111

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.

The full-text list holds two people at most: Ada's bio holds neither docs nor tests. A person missing from a list gets no share from it, so Ada's score is her vector share alone.

COMBINE on the CLI. This setup step writes the same graph, vectors and indexes to a new store, search.rl:

relate --database ./search.rl "CREATE (ada:Person {name: 'Ada', role: 'engineer', focus: vector([0.9, 0.1, 0.2], 3, FLOAT32), bio: 'Writes the storage engine in Rust'}),
       (grace:Person {name: 'Grace', role: 'engineer', focus: vector([0.3, 0.9, 0.1], 3, FLOAT32), bio: 'Keeps the conformance tests green'}),
       (noor:Person {name: 'Noor', role: 'maintainer', focus: vector([0.2, 0.3, 0.9], 3, FLOAT32), bio: 'Reviews the docs and the tests'}),
       (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);
CREATE VECTOR INDEX person_focus FOR (p:Person) ON (p.focus) WITH [p.role]
  OPTIONS {indexConfig: {'vector.dimensions': 3, 'vector.similarity_function': 'cosine'}};
CREATE FULLTEXT INDEX person_bio FOR (p:Person) ON EACH [p.bio]"

The fused ranking, read-only, with the preview opt-in in the command's environment:

RELATE_PREVIEW=hybrid-search relate --database ./search.rl --read-only --mode table "MATCH (p:Person) SEARCH p IN (COMBINE (VECTOR INDEX person_focus FOR [0.1, 0.9, 0.1] LIMIT 3 WEIGHT 0.7, FULLTEXT INDEX person_bio FOR 'docs tests' LIMIT 3 WEIGHT 0.3) USING WRRF WITH CONSTANT 60 LIMIT 3) SCORE AS score RETURN p.name AS name, score"
+---------+----------------------+
| name    | score                |
+---------+----------------------+
| "Grace" | 0.01631411951348493  |
| "Noor"  | 0.016208355367530406 |
| "Ada"   | 0.01111111111111111  |
+---------+----------------------+

Grace is first by vector and second by full-text: 0.7 / 61 + 0.3 / 62. Noor is second by vector and first by full-text: 0.7 / 62 + 0.3 / 61. Ada appears only in the vector list: 0.7 / 63. The scores are the weighted-fusion cell's, digit for digit.

See also