Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Search

MATCH (seed:Document {id: $id})-[similarity:SIMILAR_TO]->(related)
MATCH (related)-[:WRITTEN_BY]->(author)
RETURN related.title, author.name, similarity.score
ORDER BY similarity.score DESC

Find documents similar to a seed, then follow their authors through the graph. SIMILAR_TO is a computed relationship: its edges come from a search rule. WRITTEN_BY is an ordinary stored relationship. Both compose in the same query.

Define the relationship once and use the matching scoring operation with PostgreSQL/pgvector, Lance through DuckDB, Quickwit, Elasticsearch, or native scoring. The examples below assume documents have IDs, titles, body text, and embeddings; map WRITTEN_BY separately as an ordinary stored relationship.

Define the relationship

Map your columns to graph properties and describe how to select neighbors:

[node.Document]
table = "documents"
id = "id"

[node.Document.properties]
id = "id"
title = "title"
body = "body"
embedding = "embedding"

[edge.SIMILAR_TO]
source = "Document"
target = "Document"
order_by = [{ expression = "score", direction = "desc" }]
limit_per_source = 10

[edge.SIMILAR_TO.properties]
score = "vector.cosine_similarity(source.embedding, target.embedding)"

Each document has up to ten outgoing edges, ranked by cosine similarity, with a score property on each edge. Expressions reference the mapped properties using source and target. This rule includes the seed among eligible matches; see Optional filters to exclude it or restrict eligible documents.

Load this TOML mapping with GraphMapping::from_toml and register your tables as described in the mapping reference. Add one of the backend configurations below to use an existing search index. Each [[source_metadata]] block belongs at the top level of the same mapping file; declare each table once.

The relationship also works in Gremlin:

g.V().has('Document', 'id', 7).
  outE('SIMILAR_TO').as('similarity').
  inV().as('document').select('document', 'similarity')

Ranking belongs to the relationship: filtering its destinations later does not refill its top ten, and reverse traversal follows the same directed edges. Computed edges are read-only. approximate_allowed is the default; exact requests exhaustive vector ranking.

PostgreSQL with pgvector

Use a PostgreSQL documents table with a native vector(N) embedding column, where N matches your embedding dimension. Enable the installed extension and create a cosine index on your existing table:

CREATE EXTENSION IF NOT EXISTS vector;
CREATE INDEX documents_cosine ON documents
  USING hnsw (embedding vector_cosine_ops);

Bind the column in the mapping:

[[source_metadata]]
table = "documents"
format = "pgvector"
indexes = [{ column = "embedding", metric = "cosine" }]

Assign documents to a PostgreSQL engine in the compiler request. The SIMILAR_TO definition and Cypher query stay the same. For same-engine data, the search portion has this SQL shape (aliases and score guards simplified):

SELECT source.id AS source_id, hits.*
FROM documents AS source
CROSS JOIN LATERAL (
  SELECT target.id AS target_id,
         1.0 - (target.embedding <=> source.embedding) AS score
  FROM documents AS target
  ORDER BY target.embedding <=> source.embedding ASC NULLS LAST
  LIMIT 10
) AS hits
WHERE source.id = $1;

Descending similarity becomes ascending distance so PostgreSQL can use the index. The remaining graph joins can stay in the same SQL island. Dot product uses <#> and L2 distance uses <->; choose the matching index operator class and mapping metric. See pgvector indexing for index options. exact deliberately avoids ANN index selection.

Lance through DuckDB

Use a Lance dataset at /data/documents.lance with the mapped columns and fixed-size embeddings, such as FLOAT[384]. In the DuckDB connection that will execute the searches, load duckdb-lance and expose the dataset as documents:

INSTALL lance FROM community;
LOAD lance;
CREATE VIEW documents AS SELECT * FROM '/data/documents.lance';

Create a cosine index if the dataset does not already have one. This small-data example uses one partition; size the index for your dataset:

CREATE INDEX documents_cosine ON '/data/documents.lance' (embedding)
  USING IVF_FLAT WITH (num_partitions = 1, metric_type = 'cosine');

Use this binding instead of the pgvector binding:

[[source_metadata]]
table = "documents"
format = "lance"
options = { uri = "/data/documents.lance" }
indexes = [{ column = "embedding", metric = "cosine", options = { nprobes = 1, refine_factor = 2 } }]

The table and URI must identify the same dataset. The binding metric must match the index. nprobes controls IVF partitions searched; refine_factor controls candidate refinement. duckdb-lance’s SQL reference describes these backend options and index creation.

Assign documents to a DuckDB engine. OrchidDB reads the selected seed, binds its embedding into lance_vector_search, and joins the hits into the graph query. For a three-dimensional example seed, the search statement has this shape; actual vector dimensions must match your dataset:

SELECT target.id,
       list_cosine_similarity([0.1, 0.9, 0.2], target.embedding) AS score
FROM lance_vector_search(
  '/data/documents.lance', 'embedding', [0.1, 0.9, 0.2]::FLOAT[3],
  k = 10, use_index = true, nprobs = 1, refine_factor = 2, prefilter = true
) AS target;

The Cypher query and relationship rule stay unchanged. Install duckdb-lance for the DuckDB version running your queries. See the detailed reference for version compatibility and verification details.

Candidate filters must be pushable comparisons, null checks, or conjunctions. Lance exact currently supports L2; exact cosine/dot requests fail planning because the extension’s non-indexed search does not expose a metric selector.

Use document text as the query to define a lexical relationship over the same nodes:

[edge.TEXT_MATCH]
source = "Document"
target = "Document"
order_by = [{ expression = "score", direction = "desc" }]
limit_per_source = 10

[edge.TEXT_MATCH.properties]
score = "text.bm25(source.body, target.body)"

For indexed Lance retrieval, create a text index and replace the earlier documents source metadata with the combined vector and text declaration:

CREATE INDEX documents_text ON '/data/documents.lance' (body) USING INVERTED;
[[source_metadata]]
table = "documents"
format = "lance"
options = { uri = "/data/documents.lance" }
indexes = [
  { column = "embedding", metric = "cosine", options = { nprobes = 1, refine_factor = 2 } },
  { column = "body", metric = "bm25" },
]

Query the relationship normally:

MATCH (seed:Document {id: $id})-[r:TEXT_MATCH]->(related)
RETURN related.title, r.score
ORDER BY r.score DESC

For a seed whose body is graph retrieval, the backend search is shaped like:

SELECT target.id, target._score AS score
FROM lance_fts('/data/documents.lance', 'body', 'graph retrieval',
               k = 10, prefilter = true) AS target;

Lance supplies its indexed corpus statistics, tokenizer, and scores. Without a declared text index, native, PostgreSQL, and ordinary DuckDB execution compute portable BM25 over the mapped target corpus before pair filters and ranking. That mode is exhaustive scoring. pgvector does not provide a BM25 index, and OrchidDB does not substitute PostgreSQL ts_rank for BM25.

Quickwit and Elasticsearch

Enable the optional quickwit or elasticsearch Cargo feature to execute BM25 relationships against a remote index. The text.bm25 relationship definition stays the same. Assign an index table to the remote engine, or declare a remote index over a SQL-owned document table. Search results then compose with stored edges and properties in your SQL engines.

The Quickwit and Elasticsearch guide covers engine registration, index configuration, authentication, and execution. Both adapters return the engine’s indexed BM25 scores and preserve filters before top-k.

Retrieve candidates, then rerank with MaxSim

Map a tokens property to a column containing each document’s token vectors (for example, add tokens = "token_embeddings" under [node.Document.properties]). Supply embeddings from your model; normalize them if your ColBERT scoring requires cosine token similarity.

This relationship retrieves up to 200 BM25 candidates with the text binding above, then selects ten using MaxSim:

[edge.RELEVANT_TO]
source = "Document"
target = "Document"
order_by = [{ expression = "score", direction = "desc" }]
limit_per_source = 10

[edge.RELEVANT_TO.candidates]
order_by = [{ expression = "lexical", direction = "desc" }]
limit_per_source = 200

[edge.RELEVANT_TO.candidates.properties]
lexical = "text.bm25(source.body, target.body)"

[edge.RELEVANT_TO.properties]
score = "vector.maxsim(source.tokens, target.tokens)"

Traverse RELEVANT_TO in place of SIMILAR_TO in the opening query. Both score and lexical are available as edge properties. To retrieve vector candidates instead, replace the candidate’s lexical expression with vector.cosine_similarity(source.embedding, target.embedding) and use the pgvector or Lance vector binding. Candidate property names must be distinct from final property names.

MaxSim sums the best document-token inner product for each query token. Eligibility filters belong in the candidate predicate when they must apply before retrieval. Final-stage predicates filter only retrieved candidates. Removing the candidate stage makes MaxSim exhaustive over the target relation.

Available scores

All built-in scores have native implementations and PostgreSQL/DuckDB SQL mappings:

ExpressionPreferred orderInput
vector.cosine_similarity(a, b)DescendingTwo vectors
vector.dot(a, b)DescendingTwo vectors
vector.l2_distance(a, b)AscendingTwo vectors
text.bm25(query, target.body)DescendingQuery text and a mapped text property
vector.maxsim(query_tokens, document_tokens)DescendingTwo lists of token vectors

Native scoring and ordinary DuckDB scoring evaluate eligible pairs, which can be expensive for large collections. PostgreSQL vector search can use an existing matching pgvector index even without an explicit index declaration. Lance search requires the source metadata shown above. Portable BM25 and MaxSim score their inputs exhaustively; use indexed candidates to limit reranking work.

Vectors may be float32/float64 lists or fixed-size lists. Null inputs produce null scores; zero-norm cosine inputs also produce null. Indexed search requires valid query vectors with dimensions matching the index.

Optional filters

A relationship needs no predicate field unless you want to restrict eligible matches. For example, add this line under [edge.SIMILAR_TO] to exclude the seed:

predicate = "target.id <> source.id"

To restrict matches to the same tenant, map tenant_id on the nodes and use:

predicate = "source.tenant_id = target.tenant_id AND target.id <> source.id"

These are relationship rules: they apply before selecting its top ten. A WHERE clause in a consuming Cypher query filters the resulting edges and does not replace discarded matches. With a candidate stage, put eligibility filters under [edge.RELEVANT_TO.candidates] so they apply before candidate selection. A filter on the final stage applies only to those candidates.

Expressions support arithmetic, comparisons, Boolean logic, CASE, and registered scalar functions. Indexed retrieval supports the filters its backend can execute; unsupported filters produce an error.

Using multiple engines

Assign tables to engines in the compiler request. The graph query can combine search results with relationships stored in other engines; OrchidDB composes the searches, transfers, and graph joins. Index errors are reported to the caller.

See the detailed search reference for the compiler API, native execution, custom scoring functions, and backend constraints. To add support for another database, see Engine adapters.