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

Propagate Embeddings over a Graph (Decoupled GNN)

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Graph Signal Processing.

propagate_embeddings is the forward pass of a graph convolution, run as a data-plane operation. For every row of an embedding table it replaces the row’s vector with an aggregate of its k-hop neighbourhood — ÂᵏX — and writes the result as a new, ordinary embedding table (searchable, joinable, re-graphable).

This is the propagate half of a decoupled GNN. SGC showed the nonlinearities between graph-conv layers are removable: precompute the propagated features, then learn a simple head on top. APPNP added the teleport restart that keeps deep propagation from collapsing. Neither needs autograd, an architecture, or message-passing code — ÂᵏX is a graph join plus a grouped vector average, and that is all this verb is.

It composes with anything that consumes an embedding table: search the propagated vectors, evaluate them, build a neighbour graph over them, or fine-tune a head on them (the SGC/APPNP order — propagate first, then fine-tune).

When propagation helps — measure homophily first

Smoothing helps only when neighbours share signal.

Averaging a node with its neighbours denoises it when the graph is homophilous — neighbours tend to be the same kind of thing (papers cite papers on the same topic; co-purchased items share a category; KG entities of one type link to one type). Then the propagated vectors cluster tighter and downstream search / classification improves.

On a heterophilous graph — neighbours tend to differ — propagation mixes in opposing signal and is beaten by ignoring the graph entirely. This is not a silent failure mode to discover in production: measure it first. The per-edge-type homophily diagnostic reports, for each edge type, how often its endpoints share a label. Propagate over the homophilous types; for genuinely heterophilous structure the answer is learned attention (a later spec), not fixed averaging.

The default is over-smoothing-safe

Iterated averaging is exactly the operation that collapses every node into one indistinguishable point as the hop count grows (rank collapse). Three defaults keep that in check:

  • PageRank-decay weighting (DegreeNormalized + an α-teleport restart). Each hop re-mixes a share α of every node’s original embedding back in, so a node stays anchored to itself however deep you go (the APPNP fix). α defaults to 0.1.
  • Two hops by default, capped at three. Beyond that, more hops add collapse, not signal.
  • Self-loops (Ã = A + I). Every node aggregates over itself, so an isolated node propagates to its own embedding rather than vanishing, and the symmetric normalisation has no oscillating eigenmode.

Weightings

WeightingAggregationUse
DegreeNormalized (default)symmetric  = D̃^{-1/2}(A+I)D̃^{-1/2}, with the α-teleportthe safe default (SGC/APPNP)
Uniformrandom-walk mean D̃^{-1}Ã (each node = mean of itself + neighbours)unweighted graphs, simplest smoothing
EdgeSimilarityedge-weighted mean Σ(w·x)/Σwuse the edge weight as fixed attention (e.g. an S9 similarity edge); negative weights clamp to zero

Output: final block, or Jumping Knowledge

By default the output is the final propagated block X⁽ᴷ⁾, a d-dimensional embedding table in the input’s vector space.

PropagationOutput::JumpingKnowledge instead concatenates the per-hop blocks [X⁽⁰⁾ ‖ … ‖ X⁽ᴷ⁾], each L2-normalised before concat so the raw block does not dominate cosine search. This lets a downstream head pick the right receptive depth per node, but the output is (K+1)·d-dimensional and indexes in its own space — do not search it against the original d-dimensional vectors.

Example: propagate over a citation graph

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::config::JammiConfig;
use jammi_ai::pipeline::graph_neighbourhood::{EdgeDirection, EdgeSourceRef};
use jammi_ai::pipeline::graph_propagation::{PropagateRequest, PropagationWeighting};
use jammi_db::store::CachePolicy;
async fn ex(config: JammiConfig, model_id: &str) -> jammi_db::error::Result<()> {
let session = Arc::new(InferenceSession::new(config).await?);

// Embed the documents first (any embedding model).
session
    .generate_text_embeddings("papers", model_id, &["abstract".into()], "id", CachePolicy::Bypass)
    .await?;

// Propagate over a declared citation edge source (src/dst are the paper ids,
// which are the embedding keys). Citations are undirected for smoothing.
let (propagated, _outcome) = session
    .propagate_embeddings(
        &PropagateRequest::new(
            "papers",
            EdgeSourceRef::Registered {
                source_id: "citations".into(),
                src_column: "citing".into(),
                dst_column: "cited".into(),
                type_column: None,
                weight_column: None,
                as_of_column: None,
            },
        )
        .with_direction(EdgeDirection::Undirected)
        .with_weighting(PropagationWeighting::DegreeNormalized)
        .with_hops(2),
        CachePolicy::Bypass,
    )
    .await?;

// The result is an ordinary embedding table: search it, evaluate it, or graph
// it like any other.
let neighbours = session
    .sql(&format!(
        "SELECT _row_id FROM \"jammi.{}\" LIMIT 5",
        propagated.table_name
    ))
    .await?;
let _ = neighbours;
Ok(())
}
}

Propagating over an S9 similarity graph

You can also propagate over the similarity graph Jammi itself builds — pass its table name as the edge source:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::config::JammiConfig;
use jammi_ai::pipeline::graph_neighbourhood::EdgeSourceRef;
use jammi_ai::pipeline::graph_propagation::PropagateRequest;
use jammi_db::store::CachePolicy;
async fn ex(config: JammiConfig, graph_table: &str) -> jammi_db::error::Result<()> {
let session = Arc::new(InferenceSession::new(config).await?);
let (_propagated, _outcome) = session
    .propagate_embeddings(
        &PropagateRequest::new(
            "items",
            EdgeSourceRef::NeighborGraph {
                table_name: graph_table.into(),
            },
        ),
        CachePolicy::Bypass,
    )
    .await?;
Ok(())
}
}

But note the caveat from graph-supervised fine-tuning: a similarity graph is k-NN under the base metric, so propagating over it mostly re-averages things the model already thinks are close. Declared edges (a citation network, a co-purchase log, a knowledge graph’s typed relations) carry structure the base metric does not already encode — that is where propagation adds signal.

Determinism

Propagation is deterministic: every fold, teleport, and weighted sum runs in f64 over a fixed (node, neighbour) order, so the output is byte-identical regardless of how many threads the engine runs. It is the reproducible point on the structure-aware spectrum — fixed averaging, no learned parameters.

Bounds

The edge set is loaded under a row ceiling (PropagateRequest::max_rows); a graph larger than that is refused loudly rather than risking an out-of-memory pass. Whole-graph propagation beyond memory (chunking by the join) is future work.