17. graphsage - embeddings that use your columns
← Graph exampleswhat this does
GraphSAGE learns node vectors from two things at once: the shape of the graph and a numeric feature vector you already store on each row. Where node2vec sees only structure, GraphSAGE aggregates a node's own attributes together with its neighbours'. The shape is the same two calls - POST /v1/tenants/:t/graph/:schema/graphsage trains, and GET /v1/tenants/:t/graph/:schema/graphsage/:rel/topk queries the persisted result.
when to use it
- Nodes whose columns carry real signal - a price, a score set, an embedding you already computed - where structure alone is not enough.
- Cold-start cases. A node with very few edges can still be placed sensibly if its features are informative.
- Any recommendation problem where you want structure and content in a single vector instead of blending two separate rankings.
schema requirement
Beyond the [[relations]] block, GraphSAGE needs a per-row feature vector: a column holding an array of numbers, the same width on every row. It can be a declared column or an undeclared JSON array field on the row. The width you pass as feature_dim must match that column exactly - a mismatch is a 400, never a silent pad or truncate. See schemas/reference#relations.
step 1 of 2 - train and persist
rel and feature_col are the two required fields, and feature_dim must equal the real width of that column. num_layers is capped at 4 and neighbor_sample_size at 50. mean is the default. All three aggregators run. mean is fully supported; max_pool and lstm are preview - their forward pass is wired, but their own weights stay at their random starting values because the HTTP path does not train them. Only the node embedding table is learned.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.users/graphsage" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"rel": "follows",
"feature_col": "feat",
"feature_dim": 4,
"embedding_dim": 64,
"hidden_dim": 64,
"num_layers": 2,
"neighbor_sample_size": 10,
"aggregator": "mean",
"epochs": 5,
"seed": 42,
"persist": true
}'result = db.graph.graphsage(
"social.users",
feature_col="feat",
rel="follows",
config={
"feature_dim": 4,
"embedding_dim": 64,
"hidden_dim": 64,
"num_layers": 2,
"neighbor_sample_size": 10,
"aggregator": "mean",
"epochs": 5,
"seed": 42,
},
persist=True,
)const res = await fetch(
`https://${OC_HOST}/v1/tenants/${OC_TENANT}/graph/social.users/graphsage`,
{
method: "POST",
headers: {
Authorization: `Bearer ${OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
rel: "follows",
feature_col: "feat",
feature_dim: 4,
embedding_dim: 64,
hidden_dim: 64,
num_layers: 2,
neighbor_sample_size: 10,
aggregator: "mean",
epochs: 5,
seed: 42,
persist: true,
}),
},
);
const trained = await res.json();body, _ := json.Marshal(map[string]any{
"rel": "follows",
"feature_col": "feat",
"feature_dim": 4,
"embedding_dim": 64,
"hidden_dim": 64,
"num_layers": 2,
"neighbor_sample_size": 10,
"aggregator": "mean",
"epochs": 5,
"seed": 42,
"persist": true,
})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+OC_HOST+"/v1/tenants/"+OC_TENANT+"/graph/social.users/graphsage",
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.0311, -0.0142, 0.0207],
"bob": [0.0288, -0.0166, 0.0193]
},
"vocab_size": 11,
"training_pairs": 3960,
"final_loss": 0.5842,
"embedding_dim": 64,
"feature_dim": 4,
"persisted": true
}
The same envelope node2vec returns, with two differences: the width field is called embedding_dim rather than dim, and feature_dim echoes the input width back so you can confirm the engine read the column you meant. Vectors are shortened above - each one is embedding_dim floats long.
step 2 of 2 - ask for similar nodes
Identical grammar to the node2vec query: the relation is a path segment, query names the node and k the number of neighbours, and metric accepts cosine, dot, l2 or manhattan, defaulting to cosine. The two persisted sets are stored separately, so a relation can carry both a node2vec and a GraphSAGE index at once.
curl -G "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.users/graphsage/follows/topk" \
--data-urlencode "query=alice" \
--data-urlencode "k=5" \
--data-urlencode "metric=cosine" \
-H "Authorization: Bearer $OC_TOKEN"hits = db.graph.graphsage_topk(
"social.users",
rel="follows",
query_pk="alice",
k=5,
metric="cosine",
)
for hit in hits:
print(hit.pk, hit.score)const qs = new URLSearchParams({
query: "alice",
k: "5",
metric: "cosine",
});
const res = await fetch(
`https://${OC_HOST}/v1/tenants/${OC_TENANT}/graph/social.users/graphsage/follows/topk?${qs}`,
{ headers: { Authorization: `Bearer ${OC_TOKEN}` } },
);
const hits = await res.json();url := "https://" + OC_HOST + "/v1/tenants/" + OC_TENANT +
"/graph/social.users/graphsage/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.97 },
{ "pk": "dave", "score": 0.88 },
{ "pk": "carol", "score": 0.81 }
]
A flat array of { pk, score }, best first, with the query node excluded from its own results. Until a training call has run with persist: true this route answers 503 with a message pointing at the persistence step - a missing prerequisite rather than a failure.
how it works
- Each layer samples up to
neighbor_sample_sizeneighbours and averages their representations with the node's own. That averaging is themeanaggregator, andnum_layersdecides how far out the neighbourhood reaches. - Features are read per row from the feature column. Both a declared column and an undeclared JSON array field on the row work.
- The same seed, graph, features and configuration give byte-identical vectors.
persist: truewrites one blob per schema and relation, stored alongside but separately from the node2vec blob, so both indexes can exist for the same relation.- Response size is capped exactly as node2vec is:
vocab_sizetimesembedding_dimabove one million floats is a 400. - A persisted set that has gone bad can be inspected with a
healthcall on the same path and retrained in place with arebuildcall, which retrains from the rows and edges already in the store. No re-ingest is needed, and only the derived embedding blob is rewritten - no row is touched.
common mistakes
- Getting
feature_dimwrong. Declaring 128 against a four-wide column fails loudly with a 400. That is deliberate: a silently padded feature vector would produce embeddings that look fine and mean nothing. - Pointing
feature_colat a column that is not there. An unknown name is a 400, not an empty feature vector. - Asking for an aggregator other than
mean. Assuming max_pool and lstm fail. They do not - they return embeddings. What they do not do is train their own aggregator weights over HTTP, so treat them as preview rather than as a drop-in for mean. - Forgetting
persist: true. As with node2vec, the topk route answers 503 until a persisted blob exists. - Exceeding
num_layers4 orneighbor_sample_size50. Both are refused with a 400 rather than clamped.