16. node2vec - train embeddings, then query them
← Graph exampleswhat this does
node2vec learns a vector for every node from biased random walks, so nodes sitting in similar parts of the graph end up close together. It is two calls: POST /v1/tenants/:t/graph/:schema/node2vec trains, and GET /v1/tenants/:t/graph/:schema/node2vec/:rel/topk answers similarity queries. The second only works if the first was made with persist: true - without it the vectors come back in the response and nothing is stored.
when to use it
- "Nodes like this one", where similarity should come from graph position rather than from text or image content.
- Link prediction and recommendation over a relation you already model as a foreign key.
- Feeding graph structure into a downstream model as a fixed-width vector, without writing a walk sampler yourself.
schema requirement
The relation must be declared in the schema's [[relations]] block. node2vec needs no feature columns - it learns from structure alone. See schemas/reference#relations.
step 1 of 2 - train and persist
rel is the only required field; every other knob has a default, so a body of just the relation name is a complete request. Set persist: true to store the vectors under the schema and relation so the topk route becomes available. Without it the call is a pure computation: it trains in memory, returns the vectors and writes nothing.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.users/node2vec" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"rel": "follows",
"dim": 64,
"walks_per_node": 10,
"walk_length": 40,
"window_size": 5,
"epochs": 5,
"seed": 42,
"persist": true
}'# The Python SDK wraps the query side but not training -
# post the config directly.
import httpx
r = httpx.post(
f"https://{OC_HOST}/v1/tenants/{OC_TENANT}/graph/social.users/node2vec",
headers={"Authorization": f"Bearer {OC_TOKEN}"},
json={
"rel": "follows",
"dim": 64,
"walks_per_node": 10,
"walk_length": 40,
"window_size": 5,
"epochs": 5,
"seed": 42,
"persist": True,
},
)
trained = r.json()
print(trained["vocab_size"], trained["persisted"])const res = await fetch(
`https://${OC_HOST}/v1/tenants/${OC_TENANT}/graph/social.users/node2vec`,
{
method: "POST",
headers: {
Authorization: `Bearer ${OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
rel: "follows",
dim: 64,
walks_per_node: 10,
walk_length: 40,
window_size: 5,
epochs: 5,
seed: 42,
persist: true,
}),
},
);
const trained = await res.json();body, _ := json.Marshal(map[string]any{
"rel": "follows",
"dim": 64,
"walks_per_node": 10,
"walk_length": 40,
"window_size": 5,
"epochs": 5,
"seed": 42,
"persist": true,
})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+OC_HOST+"/v1/tenants/"+OC_TENANT+"/graph/social.users/node2vec",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+OC_TOKEN)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()what training returns
{
"embeddings": {
"alice": [0.0142, -0.0318, 0.0091],
"bob": [-0.0077, 0.0264, -0.0155],
"carol": [0.0203, -0.0119, 0.0288]
},
"vocab_size": 11,
"training_pairs": 4820,
"final_loss": 0.6931,
"dim": 64,
"persisted": true
} embeddings maps each node's primary key to its vector, and each vector is dim floats long - they are shortened above to keep the example readable. vocab_size is how many nodes appeared in a walk, training_pairs how many skip-gram pairs were consumed, and final_loss the loss at the end of the last epoch. persisted mirrors the request flag and is true only when the blob was actually written, so you can tell whether the next call will work without probing for it.
step 2 of 2 - ask for similar nodes
The relation name moves into the path here, because that is how the persisted blob is keyed. query is the node you are asking about and k is how many neighbours you want. metric accepts cosine, dot, l2 or manhattan and defaults to cosine.
curl -G "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.users/node2vec/follows/topk" \
--data-urlencode "query=alice" \
--data-urlencode "k=5" \
--data-urlencode "metric=cosine" \
-H "Authorization: Bearer $OC_TOKEN"hits = db.graph.node2vec_topk(
"social.users",
rel="follows",
query_pk="alice",
k=5,
metric="cosine",
)const qs = new URLSearchParams({
query: "alice",
k: "5",
metric: "cosine",
});
const res = await fetch(
`https://${OC_HOST}/v1/tenants/${OC_TENANT}/graph/social.users/node2vec/follows/topk?${qs}`,
{ headers: { Authorization: `Bearer ${OC_TOKEN}` } },
);
const hits = await res.json();url := "https://" + OC_HOST + "/v1/tenants/" + OC_TENANT +
"/graph/social.users/node2vec/follows/topk?query=alice&k=5&metric=cosine"
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "Bearer "+OC_TOKEN)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var hits []struct {
PK string `json:"pk"`
Score float32 `json:"score"`
}
json.NewDecoder(resp.Body).Decode(&hits)what the query returns
[
{ "pk": "bob", "score": 0.94 },
{ "pk": "carol", "score": 0.91 },
{ "pk": "dave", "score": 0.72 }
]
A flat array of { pk, score }, best first, with the query node itself excluded. pk is a plain string here, not the primary-key array the algorithm endpoints return. Fewer than k hits means the persisted set is smaller than k; asking for more than the engine's node ceiling truncates the result rather than failing the call.
how it works
- Training samples biased random walks from every node, then runs skip-gram with negative sampling over the resulting sequences.
pandqbias the walks exactly as they do on therandom-walkendpoint. - The same seed, graph and configuration produce byte-identical vectors, so a retrain is reproducible.
persist: truewrites one blob per schema and relation, and the next persist replaces it atomically. What is stored is the trained vectors themselves rather than the recipe - replaying training elsewhere would not land on the same numbers.- The response is capped at one million floats, which is
vocab_sizetimesdim. Above that the call is refused with a 400, because a body that large stops being something a normal gateway will carry. dimabove 1024 is refused, and so is a walk budget above ten million, where the budget iswalks_per_nodemultiplied bywalk_length.
common mistakes
- Forgetting
persist: true. Training without it returns the vectors and stores nothing, and the topk route then answers 503 telling you to post with persist first. That 503 is a missing prerequisite, not an outage. - Putting the relation in the query string on the topk call. It belongs in the path, as
.../node2vec/<rel>/topk. - Misspelling a knob. Unknown request fields are dropped rather than rejected by default, so
walk_leninstead ofwalk_lengthtrains at the default and still returns 200. Check the names against the body above. - Comparing scores across metrics. A cosine similarity and an l2 distance are not on the same scale. Pick one metric and stay with it.