Vectors

New to embeddings? Embeddings and Vector Search is the task-oriented tour: semantic search, filtered search, GraphRAG and recommendations, in the playground with toy vectors and in Python with a real model. This page is the reference.

A vector is a fixed-length list of numbers stored as one value: an embedding of a text, an image or a node, as a language or vision model computes it. A property holds a vector, not a list that happens to contain numbers, and the engine stores it compactly, converts it between element kinds and compares it as one value. Vectors are a Graphersal extension: TinkerPop has no vector type.

Embeddings are computed outside the engine. A step that called an embedding model would make a query depend on a network service, could not be rolled back (a paid call stays paid) and would not run in the browser playground. The host computes them, in Python, in an MCP tool or in a server, and writes them as ordinary property values.

type_of(vector([0.12, -0.5, 0.33]))   // "Vector"
vector([0.12, -0.5, 0.33]).dimension   // 3
vector([0.12, -0.5, 0.33], "i8").element   // "i8"
g.inject(vector([0.5, 1.0])).is(P.typeOf(GType.VECTOR)).count().next()   // 1

Element kinds and quantization

A vector has 1 to 65 536 components, all of one element kind:

ElementA componentBytes per component768 components
f32 (default)an IEEE single-precision float43 KiB
f16an IEEE half-precision float21.5 KiB
i8an 8-bit code times one f32 scale per vector (value = code * scale)1768 B
bitone sign bit: 1.0 for a set bit, -1.0 otherwise1/896 B

A list of 768 numbers as a plain array takes about 24 KiB; the same vector as f32 3 KiB.

Quantization converts a vector to another element kind, to save memory at a small cost in precision:

  • f32 to f16 rounds every component to the nearest half value (ties to even). A component beyond the half range (65 504) is an error, never an infinity.
  • f32 to i8 is symmetric scalar quantization: scale = max |x| / 127, code = round(x / scale) in -127..=127. A zero vector has scale 0.
  • f32 to bit keeps the sign: a component > 0 sets its bit (binary quantization).
  • Every other conversion goes through the f32 values, so quantize("f32") dequantizes.
vector([0.5, -1.0, 0.25]).quantize("bit").to_list()   // [1.0, -1.0, 1.0]
vector([0.5, -1.0, 0.25]).quantize("f16").to_list()   // [0.5, -1.0, 0.25]
g.inject(vector([0.5, -1.0, 0.25])).quantize("i8").next().element   // "i8"
vector([1e10], "f16")   // fails: the component is beyond the range of an f16 vector

All four kinds are part of the value type and of every file format from the start, so storing a property quantized is a choice of the data, never a format change.

Building vectors

  • vector(list) builds an f32 vector of a list of numbers; vector(list, kind) quantizes it to "f16", "i8" or "bit".
  • The as_vector() step converts a list of numbers in a traversal (a stored list, a fold()); a vector passes, null stays null.
  • A schema that declares the property as a vector converts a written list on write (below).

A vector never holds NaN or an infinity (it would break ordering and comparison); -0.0 is kept and equals 0.0. An empty list, a component that is not a number and more than 65 536 components are errors:

g.inject([0.5, 1]).as_vector().next()   // vector([0.5, 1.0])
g.inject(1, 2, 3).fold().as_vector().next().dimension   // 3
vector([])   // fails: a vector has 1 to 65536 components
vector([1, "x"])   // fails: component 1 is not a number

A vector prints as its DSL literal, which evaluates back to the same vector (an i8 or bit vector shows its dequantized values and its kind): vector([0.5, 1.0]), vector([0.503937, -1.0], "i8"). to_string() and the step names in .profile() cut it after 16 components, like a list. In a script a vector has the getters element and dimension and the methods quantize(kind) and to_list() (the values as floats).

Writing, filtering and ordering

A vector is written like any other value, and has() compares it by value: the same element kind, the same dimension and equal components. A list is never equal to a vector, and an f16 vector never equals an f32 one, even with the same values:

g.add_v("doc").property("title", "Graphs").property("emb", vector([0.1, 0.9])).iterate();
g.add_v("doc").property("title", "Vectors").property("emb", vector([0.8, 0.2])).iterate();
g.v().has("emb", vector([0.1, 0.9])).values("title").next();   // "Graphs"
g.v().has("emb", [0.1, 0.9]).count().next();   // 0
g.v().has_label("doc").order().by("emb").values("title").to_list()   // ["Graphs", "Vectors"]

Vectors order (a Graphersal extension) after the dates and before strings; among themselves by element kind (f32, f16, i8, bit), then by dimension, then component by component. A vector is one value in the local steps (count(Scope.local) is 1, unfold() passes it), and min()/max() refuse it.

A property index never holds vectors: has(key, vector) on an indexed key reads the elements by a scan, with the same result.

Quantize an existing property with one write per element, then declare the new element in the schema (g.patch_schema(..), below). This works as shown while no schema declares the property; when the schema already declares it as f32 (mode open or closed), a write is converted back to f32 and the new element is refused while f32 data remains: the migration recipe for that case (mode none for the rewrite, one unit) is in Embeddings and Vector Search.

g.add_v("doc").property("emb", vector([0.5, -1.0, 0.25])).iterate();
g.v().has_label("doc").has("emb").property("emb", __.values("emb").quantize("i8")).iterate();
g.v().values("emb").next().element   // "i8"

Vectors in a schema

A schema declares a vector property as an array with the custom format vector:

{"type": "array", "format": "vector", "element": "i8", "dimension": 768, "metric": "cosine"}
  • element (default f32) and dimension (optional: any dimension) are checked exactly: a stored vector of another kind or length does not conform. metric (cosine, dot, l2, hamming) is an annotation: the metric the vectors are meant to be compared with, which similarity() and nearest() use when the query names none (Metrics). It never changes or checks any data.
  • items is not allowed next to the format (the components are numbers of the element kind).
  • Another JSON Schema validator ignores the unknown format and keywords and sees an array of numbers, which is what a vector exports as. The schema visualizer's per-label JSON Schema (visualize("jsonschema")) spells out items and minItems/maxItems for it.

In mode open or closed, a list of numbers written to the property becomes a vector of the declared element, and a vector of another element is quantized to it: a property is stored quantized from the start, whatever the host sends. The dimension is never changed:

g.set_schema(#{mode: "open", vertices: #{doc: #{schema: #{type: "object", properties: #{
  emb: #{type: "array", format: "vector", element: "i8", dimension: 3}}}}}});
g.add_v("doc").property("emb", [0.5, -1.0, 0.25]).iterate();
g.add_v("doc").property("emb", vector([1.0, 2.0, 3.0])).iterate();
g.v().values("emb").to_list().map(|v| v.element)   // ["i8", "i8"]

On that graph, g.add_v("doc").property("emb", [1.0, 2.0]) fails with "Type mismatch on vertex 'doc.emb': expected vector<i8, 3>, got vector<i8, 2>", and its help names the conversion (values("emb").as_vector().quantize("i8")).

infer_schema() reports a vector as such, with its dimension when every vector of that element at the position has the same one. diff_schema() counts a change of element or dimension (and a list turned into a vector, or back) as incompatible, since the stored data no longer conforms; dropping dimension (any dimension) is compatible. A saved query takes a vector parameter the same way: params: #{q: #{schema: #{type: "array", format: "vector", element: "f32", dimension: 768}}}; a list argument is converted to the declared vector.

Similarity

similarity() scores a vector against a query vector q and yields a float64. It is a map step in two forms:

FormScores
similarity(key, q[, metric])the vector property key (a name or a jpath(..)) of the incoming element (or map)
similarity(q[, metric])the incoming value itself, a vector (values("emb").similarity(q), a computed vector)

q is a vector, a list of numbers (made an f32 vector when the step is built), or an anonymous traversal run on each incoming traverser whose first result is the query (__.select("q")). The key form drops an element without the property or whose value is not a vector; the value form drops null and fails on any other value that is not a vector.

g.add_v("doc").property("title", "Graphs").property("emb", vector([0.6, 0.8])).iterate();
g.add_v("doc").property("title", "Vectors").property("emb", vector([1.0, 0.0])).iterate();
g.v().has_label("doc").similarity("emb", vector([1.0, 0.0])).to_list();   // [0.6000000095367428, 1.0]
g.v().has_label("doc").values("emb").similarity([0.0, 0.0], "l2").to_list();   // [1.000000023841858, 1.0]
g.v().has_label("doc")
 .project("title", "score").by("title").by(__.similarity("emb", vector([1.0, 0.0])))
 .to_list()

Metrics

MetricScoreCloser isVectors
cosine (default)the dot product over the product of the norms, -1 to 1; 0.0 when either vector is zero (never NaN)higherany kind except bit against non-bit
dotthe dot producthigheras cosine
l2the Euclidean distancesmalleras cosine
hammingthe number of differing bitssmallerbit vectors only

The metric is the step's argument if it names one; otherwise the metric annotation the stored schema declares for the property (a top-level name on a vertex or edge, by the element's labels like a write; for the value form, the property a values("emb") reference points to), and cosine without one. A computed vector, a jpath key and a map have no declaration: cosine.

Scores are exact: every component is read from the stored kind (an f16 or i8 vector dequantized, bits counted) and summed in f64, in a fixed order, so the same vectors always give the same score. Vectors of different element kinds compare through their values: an i8 property can be searched with an f32 query. A bit vector compares only with another bit vector (for which the dot product, cosine and distance follow exactly from the Hamming distance, the components being ±1). Two vectors of different dimensions are an error, never truncated:

g.inject(vector([1.0, 0.0])).similarity(vector([1.0, 0.0, 0.0])).next()   // fails: vectors of dimensions 2 and 3 cannot be compared
g.inject(vector([1.0, -1.0], "bit")).similarity(vector([1.0, -1.0])).next()   // fails: a bit vector compares only with another bit vector
g.inject(vector([1.0, -1.0], "bit")).similarity(vector([1.0, 1.0], "bit"), "hamming").next()   // 1.0

Exact search: nearest()

nearest(key, q, k[, #{metric: ".."}]) keeps the k incoming elements whose vector property key is closest to q, best first (the highest cosine or dot, the smallest l2 or hamming). It is an exact search: every incoming element is scored, with a bounded heap of k (O(n log k), never a full sort).

  • Elements without the property, or whose value is not a vector, are skipped.
  • Equal scores keep the input order, the earlier element first: the answer is deterministic, the same in a live graph, a view at a commit and a fork.
  • q is a vector, a list of numbers or a traversal. A traversal runs for every scored element and must give the same vector each time (one query per search, "more like this" below); a query that depends on the element fails and names similarity(), which scores each element against its own vector.
  • The metric follows the same rule as for similarity(). Without a metric argument, elements whose labels declare different metrics fail: one search ranks with one metric.
  • Bulk counts as in limit(): a traverser that stands for n equal ones is n results, cut at k.
  • .profile() shows the step as written, nearest("emb", vector([..]), 10, #{metric: "l2"}); the deadline (evaluationTimeout) and a cancel stop it while it scores.
g.add_v("doc").property("title", "Graph databases 101").property("emb", vector([0.9, 0.1, 0.0])).iterate();
g.add_v("doc").property("title", "Vector search").property("emb", vector([0.1, 0.9, 0.1])).iterate();
g.add_v("doc").property("title", "Indexes").property("emb", vector([0.6, 0.6, 0.2], "i8")).iterate();
g.add_v("doc").property("title", "Draft").iterate();
g.v().has_label("doc").nearest("emb", vector([1.0, 0.2, 0.0]), 2).values("title").to_list();   // ["Graph databases 101", "Indexes"]
g.v().has_label("doc").nearest("emb", [0.0, 1.0, 0.0], 1, #{metric: "l2"}).values("title").next()   // "Vector search"

nearest() is the same as ordering by the score and keeping k, which is how to get the scores along with the elements:

g.add_v("doc").property("title", "a").property("emb", vector([1.0, 0.0])).iterate();
g.add_v("doc").property("title", "b").property("emb", vector([0.6, 0.8])).iterate();
let q = vector([1.0, 0.1]);
g.v().has_label("doc").nearest("emb", q, 2)
 .project("title", "score").by("title").by(__.similarity("emb", q))
 .to_list()

Filter first, then search: has_label() and has() before nearest() reduce what it scores (a has_label() folds into v() as usual, a property index serves a has()), and a where() can filter by the graph around each element:

g.add_v("team").property("name", "engineering").as("eng")
 .add_v("doc").property("title", "Design").property("lang", "en").property("year", 2026)
   .property("emb", vector([0.9, 0.1])).add_e("owned_by").to("eng")
 .add_v("doc").property("title", "Entwurf").property("lang", "de").property("year", 2026)
   .property("emb", vector([0.9, 0.1])).add_e("owned_by").to("eng")
 .iterate();
g.v().has_label("doc")
 .has("lang", "en").has("year", P.gte(2026))
 .where(__.out("owned_by").has("name", "engineering"))
 .nearest("emb", vector([1.0, 0.0]), 5)
 .values("title").to_list()   // ["Design"]

GraphRAG: a vector hit, then the graph

Retrieval-augmented generation over a graph finds the chunks nearest to the question's embedding and expands along the edges to build the context:

g.add_v("doc").property("title", "Graphersal").as("d")
 .add_v("entity").property("name", "Rhai").as("e")
 .add_v("chunk").property("text", "Graphersal runs Gremlin in Rhai.").property("emb", vector([0.9, 0.2]))
   .add_e("part_of").to("d")
 .add_e("mentions").from("d").to("e")
 .iterate();
g.v().has_label("chunk").nearest("emb", vector([1.0, 0.1]), 5).as("hit")
 .out("part_of").as("doc")
 .out("mentions").has_label("entity").as("ent")
 .select("hit", "doc", "ent").by("text").by("title").by("name")
 .to_list()

The embedding of the question comes from the host (the model is never called by the engine), as a query parameter or the argument of a saved query: params: #{q: #{schema: #{type: "array", format: "vector", dimension: 384}}} with the body g.v().has_label("chunk").nearest("emb", q, k). MCP clients and the AI Chat call such a query with run_saved_query.

More like this

The query vector can come from the graph itself: label it, then search with __.select(..):

g.add_v("doc").property("id", "d42").property("emb", vector([1.0, 0.0])).iterate();
g.add_v("doc").property("id", "d7").property("emb", vector([0.9, 0.1])).iterate();
g.add_v("doc").property("id", "d9").property("emb", vector([0.0, 1.0])).iterate();
g.v().has("id", "d42").as("me").values("emb").as("q")
 .v().has_label("doc").where(P.neq("me"))
 .nearest("emb", __.select("q"), 2)
 .values("id").to_list()   // ["d7", "d9"]

Recommendations with vector_mean()

vector_mean() is a reducing barrier (like mean()): the component-wise mean of the incoming vectors, as an f32 vector. An incoming list of vectors (fold()) counts each of its vectors, null is skipped, quantized vectors count with their values, an empty stream gives no result, and vectors of different dimensions fail. local(__.unfold().vector_mean()) averages each list on its own. The mean of what a user liked is a taste vector to search with:

g.add_v("user").property("name", "u7").as("u")
 .add_v("article").property("title", "Graphs").property("emb", vector([1.0, 0.0])).as("a")
 .add_v("article").property("title", "Trees").property("emb", vector([0.8, 0.4])).as("b")
 .add_v("article").property("title", "Cooking").property("emb", vector([0.0, 1.0]))
 .add_v("article").property("title", "Forests").property("emb", vector([0.9, 0.3]))
 .add_e("liked").from("u").to("a").add_e("liked").from("u").to("b").add_e("read").from("u").to("a")
 .add_e("read").from("u").to("b")
 .iterate();
g.v().has("name", "u7").out("liked").values("emb").fold().vector_mean().as("taste")
 .v().has_label("article")
 .where(__.in("read").has("name", "u7").count().is(0))
 .nearest("emb", __.select("taste"), 1)
 .values("title").next()   // "Forests"

Performance

The search is an exact scan: its time grows with the number of scored elements times the dimension, and it needs no index, so it costs nothing to keep up to date and answers the same at every commit, mark and snapshot. Measured with the bench nearest_10k_x_384 (cargo bench -p graphersal --bench graph --features io,script,display,serde -- nearest_10k_x_384): 10 000 vertices with a random 384-dimensional embedding, v().has_label("doc").nearest("emb", q, 10), on an Apple M3:

Stored elementMetricMedianPer vector
f32cosine1.3 ms130 ns
f16cosine2.3 ms230 ns
i8cosine1.8 ms180 ns
bithamming0.21 ms21 ns

So about 13 ms per 100 000 f32 vectors of 384 components: enough for most in-memory graphs, and a filter before the search (above) scores fewer. There is no approximate (ANN) index yet (see Current Limitations).

Quantization: memory against accuracy

A quantized property takes less memory and gives approximately the same answers. Measured by the test quantized_search_recall_against_f32 on 2 000 random 128-dimensional vectors (20 queries, recall@10 against the f32 search with cosine); random vectors are the hardest case, real embeddings usually keep more:

Stored asBytes per 384 componentsrecall@10
f321 5361.00 (exact)
f167681.00
i8384 (+ 4 for the scale)0.99
bit (hamming)480.30
bit top 100, re-ranked by f3248 + 1 5360.74

Bit vectors are a coarse, very fast prefilter: keep both properties and re-rank the prefilter's hits exactly, nearest("emb_bit", q.quantize("bit"), 100, #{metric: "hamming"}) followed by nearest("emb", q, 10).

Storage and exchange

FormatA vector
Graphersal's binary format (snapshots, the Store, the journal)its own value tag: the element kind, the dimension, for i8 the scale, then the packed components (format spec 3)
Serialized ChangeSetexact, tagged: {"@type": "graphersal:Vector", "@value": {"element": "f32", "data": [..]}} (Transactions)
JSON results, to_json()the list of its values (an i8 or bit vector dequantized)
GraphSON exporta g:List of g:Float, which TinkerGraph loads
GraphSON importa list stays a list; it becomes a vector where the target schema declares one (mode open/closed); the exact graphersal:Vector tag is read too (GraphSON)
GraphMLthe JSON text of the list; a vector again where the target graph's schema declares one (any mode)

Exporting an i8 or bit vector as its values loses nothing that matters: a declared i8 property quantizes them back to the same codes. memory_usage() counts a vector's components exactly (plus a small header), and the memory budget of a query estimates them the same way.

From Python

A vector property is a graphersal.Vector in Python, built from a list of numbers or from any one-dimensional float32/float64 buffer such as a numpy array (the extension does not depend on numpy):

import numpy as np
from graphersal import Graph, Vector

g = Graph()
emb = np.array([0.12, -0.5, 0.33], dtype=np.float32)      # model.encode(text) in practice
g.execute('g.add_v("doc").property("emb", e).iterate()', params={"e": Vector(emb)})
v = g.query('g.v().values("emb").next()')                # graphersal.Vector
v.element, v.dimension, v.tolist()                       # ('f32', 3, [0.12, -0.5, 0.33])
Vector(emb, element="i8")                                # quantized, 1 byte per component

q = np.array([0.1, -0.4, 0.3], dtype=np.float32)          # the question's embedding
g.query('g.v().nearest("emb", q, 10).to_list()', params={"q": q})

A one-dimensional float32/float64 numpy array passed as a parameter arrives as an f32 vector, like Vector(array). See the Python binding's README for the details.

In the playground and the CLI

The result table shows a vector as vector[768 f32] with an expandable preview of its first values; the JSON view holds all of them. The property editor edits a vector as a JSON list of numbers (a declared vector property offers the type vector). The CLI's tables show the kind and the first values (vector[768 f32] [0.012, -0.5, 0.33, 0.1, …]), and a final vector prints as its literal.