Docs
View as markdown

Migrate from a vector store

Move retrieval from a vector store such as Chroma, LanceDB or pgvector into relate, keeping the embeddings and turning metadata into graph structure.

cyphercli

Before you start: Search by vector similarity

This guide shows you how to move retrieval from a vector store, such as Chroma, LanceDB or pgvector, into relate. Each record of an id, a text, some metadata, and an embedding becomes a 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: the embedding a typed vector 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 under 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, the metadata properties and relationship A directed, typed connection between two nodes, with properties of its own; every relationship has exactly one type and a start and an end node. Read more in the glossary. The top-k query becomes a SEARCH clause with the same filter, and nothing is re-embedded. The cells run in page order on one in-memory store, in your browser and in the docs build, on a five-record export; the sources themselves are described here, not run.

Prerequisites

  • An export of the records as JSON, each with its id, text, metadata and embedding. Every vector store has its own export route; this guide starts from the JSON.
  • The embedding model's dimension count and the distance metric the source ranks by.
  • Search by vector similarity for the SEARCH clause itself.

Map the records

In a vector storeIn relate
A collection or tableA label (:Chunk) with a vector index on it
A record idA property with a uniqueness constraint, so the import can MERGE and a rerun adds nothing
The document textA string property, with a full-text index for keyword search
The embeddingA typed vector property: vector(<list>, <dimensions>, FLOAT32)
Scalar metadata used in filtersProperties declared in the vector index's WITH [...] list
Metadata that names another record, such as a parent documentA relationship to that record's node
The distance metricvector.similarity_function: 'cosine' or 'euclidean'

Create the schema

Create the keys and the indexes before the first row arrives. vector.dimensions is the model's dimension count; this export uses 4 so the numbers stay readable. WITH [c.source, c.year] declares the two metadata fields a search can filter on:

Cypher
CREATE CONSTRAINT chunk_id IF NOT EXISTS FOR (c:Chunk) REQUIRE c.id IS UNIQUE;
CREATE CONSTRAINT document_id IF NOT EXISTS FOR (d:Document) REQUIRE d.id IS UNIQUE;
CREATE VECTOR INDEX chunk_embedding IF NOT EXISTS
  FOR (c:Chunk) ON (c.embedding) WITH [c.source, c.year]
  OPTIONS {indexConfig: {`vector.dimensions`: 4, `vector.similarity_function`: 'cosine'}};
CREATE FULLTEXT INDEX chunk_text IF NOT EXISTS FOR (c:Chunk) ON EACH [c.text]
Recorded result
(no rows) · indexes added: 2, constraints added: 2

A source that ranks by inner product (dot product) over vectors of length 1 ranks them as 'cosine' does. Over vectors of other lengths the two orders can differ, because the inner product also weighs each vector's length.

Import the records

The export arrives as one parameter. This cell writes a Chunk per record and a Document per distinct doc value, linked by PART_OF. MERGE on the id makes a rerun a no-op, and vector(r.embedding, 4, FLOAT32) fails the whole statement on an embedding of another length:

Cypher
UNWIND $rows AS r
MERGE (c:Chunk {id: r.id})
  ON CREATE SET c.text = r.text, c.source = r.source, c.year = r.year,
                c.embedding = vector(r.embedding, 4, FLOAT32)
MERGE (d:Document {id: r.doc})
MERGE (c)-[:PART_OF]->(d)
RETURN count(c) AS imported
Parameters
Recorded result
imported
5

1 row · nodes created: 8, relationships created: 5, properties set: 28, labels added: 8

On the CLI, the same two steps write to a .rl file. relate query with no statement reads a script from stdin, so the schema statements above, saved as schema.cypher, run first. Then --fetch-params binds the export file as the parameter. Both commands write to ./chunks.rl:

relate query --database ./chunks.rl < schema.cypher
relate --database ./chunks.rl --fetch-params export=./export.json 'UNWIND $export.rows AS r MERGE (c:Chunk {id: r.id}) ON CREATE SET c.text = r.text, c.source = r.source, c.year = r.year, c.embedding = vector(r.embedding, 4, FLOAT32) MERGE (d:Document {id: r.doc}) MERGE (c)-[:PART_OF]->(d) RETURN count(c) AS imported'

A read opens the store with --read-only, and the indexes are there:

relate --database ./chunks.rl --read-only --mode table "SHOW INDEXES YIELD name, type RETURN name, type ORDER BY name"

Migrate from JSON files covers loading files with keyed MERGE statements and a check against the files.

Rewrite the recall query

A top-k search with a metadata filter becomes SEARCH with the filter inside the parentheses, where it runs before LIMIT. SCORE AS names the similarity, and higher is closer:

Cypher
MATCH (c:Chunk)
SEARCH c IN (VECTOR INDEX chunk_embedding FOR $question
             WHERE c.source IN $sources AND c.year >= $since LIMIT $k) SCORE AS score
RETURN c.id AS id, round(score, 4) AS score, c.text AS text
ORDER BY score DESC, id
Parameters
Recorded result
idscoretext
"c1"0.9975"New engineers pair with a maintainer for their first week."
"c2"0.997"The first week ends with a small fix merged to main."

2 rows

Follow the graph from each hit

The search binds c like any MATCH, so the same statement walks from each hit: here, to its document and the document's other chunks, the context a passage on its own lacks:

Cypher
MATCH (c:Chunk)
SEARCH c IN (VECTOR INDEX chunk_embedding FOR [0.15, 0.85, 0.2, 0.05] LIMIT 1) SCORE AS score
MATCH (c)-[:PART_OF]->(d:Document)
RETURN c.id AS hit, d.id AS document,
       COLLECT { MATCH (d)<-[:PART_OF]-(s:Chunk) WHERE s <> c RETURN s.text ORDER BY s.id } AS context
Recorded result
hitdocumentcontext
"c3""incident-7"["Splitting the suite across four runners brought build times back down."]

1 row

The same nodes answer keyword search through the full-text index, so exact words and codes that an embedding blurs still match:

Cypher
MATCH (c:Chunk)
SEARCH c IN (FULLTEXT INDEX chunk_text FOR 'suite runners' LIMIT 3) SCORE AS score
RETURN c.id AS id, score
ORDER BY score DESC, id
Recorded result
idscore
"c4"0.9790317299047325
"c3"0.41168875365010643

2 rows

Hybrid recall fuses the two ranked lists by reciprocal rank in one statement and hops from each note to its topic.

Know what changes

The ranking keeps its order; the numbers, the filter grammar and a few features do not carry over:

TopicIn relate
Score directionSCORE AS is a similarity: higher is closer. A store that ranks by distance sorts ascending; the recall query sorts DESC
Cosine scale0 to 1: 1.0 same direction, 0.5 orthogonal, 0.0 opposite. A threshold tuned on another store's numbers needs converting; a raw cosine t becomes (1 + t) / 2
Similarity functions'cosine' and 'euclidean' only; any other vector.similarity_function fails with semantic error: InvalidInputValue
Dimensions1 to 4096 per index; 4097 fails with semantic error: NumberOutOfRange
FiltersEquality, IN a list, or a range per property, joined by AND, on the properties in WITH [...] only. OR and <> fail with InvalidSearchFilterPredicate; another property fails with WrongPropertyForVectorSearchFilter
PagingLIMIT is required, and a vector search takes no SKIP or OFFSET: syntax error: InvalidUseOfCommand: SKIP cannot be used together with VECTOR INDEX
Embeddingsrelate stores the vectors the application computes; it has no embedding function
SearchExact nearest neighbors: every search scores each indexed vector and keeps the top LIMIT. Ties go to the lower element id. The vector.hnsw.* options are accepted and SHOW INDEXES reports them, but they don't change the search
MemoryThe whole graph, vectors and indexes included, is held in memory (sizing)
WritersOne process writes a store at a time (limitations)

The search reference has every rule with its error.

Verify

Compare what arrived with what the source held. The source's counts per metadata value go in as a parameter, and every row should say true:

Cypher
UNWIND keys($bySource) AS source
RETURN source, $bySource[source] AS expected,
       COUNT { MATCH (c:Chunk) WHERE c.source = source } AS actual,
       $bySource[source] = COUNT { MATCH (c:Chunk) WHERE c.source = source } AS ok
ORDER BY source
Parameters
Recorded result
sourceexpectedactualok
"tickets"22true
"wiki"33true

2 rows

A search skips, without an error, a property whose length differs from vector.dimensions, so counts stay right while recall loses the node. A type predicate finds every such node, and any embedding stored as a plain list rather than a typed vector. No rows means none:

Cypher
MATCH (c:Chunk)
WHERE NOT c.embedding IS :: VECTOR<FLOAT32>(4)
RETURN c.id AS id, valueType(c.embedding) AS stored
Recorded result
| id | stored |
|----|--------|
0 rows

Every chunk searched with its own embedding comes back first, with a score of 1.0, unless two chunks' embeddings point the same way (under 'cosine') or are equal (under 'euclidean'). Then the tie goes to the lower element id. A chunk missing from its own result is one the index does not hold:

Cypher
MATCH (x:Chunk)
CALL (x) {
  MATCH (c:Chunk)
  SEARCH c IN (VECTOR INDEX chunk_embedding FOR x.embedding LIMIT 1) SCORE AS s
  RETURN c.id AS top, s
}
RETURN x.id AS id, top, round(s, 4) AS score, top = x.id AS ok
ORDER BY id
Recorded result
idtopscoreok
"c1""c1"1.0true
"c2""c2"1.0true
"c3""c3"1.0true
"c4""c4"1.0true
"c5""c5"1.0true

5 rows

The strongest check replays questions whose answers the old store gave, with the same vectors and k, and compares the ids. relate's search is exact, so the ids match except where two scores tie, which relate orders by element id, or where the old store's index was approximate. An id missing from the top of the list is a missing node.

If it goes wrong

An embedding of the wrong length fails the import, and nothing from that statement is written:

Cypher
UNWIND $rows AS r
MERGE (c:Chunk {id: r.id})
  ON CREATE SET c.text = r.text, c.embedding = vector(r.embedding, 4, FLOAT32)
RETURN count(c) AS imported
Parameters
Expected error
error: type error: vector() dimension does not match list
MessageCauseRemedy
type error: vector() dimension does not match listAn exported embedding's length is not the vector() call's dimensionsRe-export that record, or check that every record came from the same model.
type error: VectorIndexDimensionalityMismatchThe question vector's length is not the index's vector.dimensionsEmbed the question with the model that embedded the records.
syntax error: SyntaxError: InvalidSearchFilterPredicate: invalid predicate for vector search filtering. …The filter inside the search uses OR or <>Split an OR into one search per branch and combine the rows, or filter after the search.
WrongPropertyForVectorSearchFilterThe filter names a property missing from the index's WITH [...] listDrop the index and create it again with the property in WITH [...], or filter after the search.
A record never comes back, and nothing failsIts embedding, a typed vector or a plain list, has a length other than the index's vector.dimensionsRun the type predicate in Verify, then store the vectors with vector(<list>, <dimensions>, FLOAT32).
  • Search: index options and defaults, every SEARCH rule, and the vector functions.
  • Search by vector similarity: typed vectors, filters inside the search, and the score scales.
  • Hybrid recall: vector and full-text search fused in one statement.
  • Hybrid retrieval for RAG in the capability catalog.
  • The relate-migrate-from-vector-search task skill, which gives an agent this mapping and the verification probes as reference text, at https://relatedb.ai/skills/relate-migrate-from-vector-search/SKILL.md (install a task skill).
  • Migration rehearsal: fingerprints before and after a change, for the first schema change on the new store.