Figures 1 and 2 use a seven-chunk toy graph and run in your browser. Section 6 and the end of Section 7 show what the pip package computes.

The spectral part of SpectraFuse, explained visually

How a graph’s Laplacian finds communities, why its eigenvectors make good “map coordinates” for chunks, and how the same matrix spreads relevance. With every formula, two live visuals you can drive while you explain, and the exact code behind them.

1The question the spectral part answers

Dense embeddings tell you what a chunk says. SpectraFuse also wants to know where a chunk sits: which chunks belong to the same neighbourhood of the corpus, even when they share few words. The spectral part answers that from the chunk graph alone, with linear algebra instead of hand-written rules.

Throughout this page we use a seven-chunk graph that mirrors the main SpectraFuse demo. Chunks c1–c3 are about Project Orion, c4 is the Orion team chunk (“the research is carried out by Helix Labs”), and c5–c7 are about Helix Labs. Inside each group the links are strong. Between the groups there is one weak link, c4–c6, the bridge a multi-hop question needs.

EdgesWeight WijMeaning
c1–c2, c2–c3, c1–c31.0Orion chunks, tightly linked
c4–c2, c4–c30.8The team chunk belongs with Orion
c4–c60.3 (adjustable)The bridge: shared entity “Helix Labs”
c5–c6, c6–c7, c5–c71.0Helix chunks, tightly linked

2Springs and the Laplacian

Picture every chunk as a ball and every edge as a spring whose stiffness is its weight. Each chunk’s degree is the total stiffness attached to it:

di = Σj Wij,   D = diag(d1, …, dn)degree

The Laplacian packages the whole spring system into one matrix. SpectraFuse uses the symmetric normalised version, which divides each spring by the degrees at both ends so that heavily connected chunks don’t dominate:

L = D − W,    Lsym = I − D−½ W D−½paper Eq. 4, 5, 11

Why this matrix? Give every chunk a number xi (think: how far that ball is displaced). The Laplacian measures how much those numbers disagree across springs:

x⊤ Lsym x = Σ(i,j) ∈ E Wij ( xi√di − xj√dj )2spring energy

Each term is stiffness × stretch². Giving very different numbers to chunks joined by a stiff spring is expensive; giving different numbers across a weak spring is cheap.

So the cheapest ways to assign numbers keep strongly linked chunks equal and put all the disagreement on weak links. Weak links are exactly the boundaries between communities. That is the entire trick.

3Wobble patterns are eigenvectors

The “cheapest ways to assign numbers” are the eigenvectors of Lsym, ordered by their eigenvalues:

Lsym uk = λk uk,   0 = λ1 ≤ λ2 ≤ … ≤ λn ≤ 2paper Eq. 12
λk = uk⊤ Lsym uk = min‖x‖ = 1, x ⟂ u1…uk−1 x⊤ Lsym xRayleigh quotient

Each eigenvector uk gives one number per chunk: how far that chunk moves in pattern k. Its eigenvalue λk is that pattern’s spring energy, so a small λ means a cheap pattern that only stretches weak springs. Physically, each pattern is a vibration mode, and low-energy modes are the slow, lazy wobbles.

The first pattern is always trivial. It moves everything together in proportion to √degree, so nothing is stretched and λ1 = 0:

u1(i) = √di√(Σj dj),   λ1 = 0trivial pattern

The second pattern, u2 (the Fiedler vector), is the cheapest way to make chunks disagree at all. It is the relaxed solution of the normalised-cut problem: split the graph in two while cutting as little spring stiffness as possible. Try it below.

Figure 1. Each pattern is one eigenvector of LsymAnimated: nodes move by A · uk(i) · sin(ωk t)
Pattern
Positive: moves one wayNegative: moves the other wayNumber under a chunk = uk(i)
0.30

Where this pattern’s energy comes from

Each bar is Wij(ui/√di − uj/√dj)² for one edge. The bars add up to λ exactly. The gold bar is the bridge.

Sum of edge costs

All seven eigenvalues

Try this while presenting: start on pattern 1 (everything moves together, energy 0), switch to pattern 2 and point at the gold bar carrying most of the energy, then drag the bridge slider toward 0.05 and watch λ2 shrink. Finally press “Spectral map”.

With the default bridge of 0.3, λ2 = 0.066 and about four fifths of that energy sits on the single c4–c6 spring: u2 keeps each group internally calm and does all its stretching on the weak link. Weaken the bridge and λ2 heads toward zero, the signature of two nearly separate communities. Strengthen it and λ2 grows while c4’s value drifts toward zero, because c4 is now pulled by both sides.

Higher patterns cost much more (λ3 ≈ 1 here). They can no longer hide their stretch on the weak link, so they make chunks disagree inside groups. That is finer detail, useful but less about communities.

Rule of thumb, the eigengap: the number of eigenvalues close to zero before a big jump is roughly the number of well-separated communities. Here λ1, λ2 ≈ 0 and then λ3 ≈ 1: two communities.

4From patterns to spectral coordinates

Stack the first few non-trivial eigenvectors as columns, and read the table row by row. Each chunk’s row is its spectral coordinate:

U = [u2, u3, …, um+1] ∈ ℝn×m,   zi = [u2(i), u3(i), …, um+1(i)] ∈ ℝmpaper Eq. 13

SpectraFuse keeps m = 32 coordinates per chunk (the demo uses 4; the figure above shows 2). Pressing “Spectral map” in Figure 1 places each chunk at (u2(i), u3(i)): the Orion chunks land on one side, the Helix chunks on the other, and c4 sits toward the middle.

Because strongly linked chunks get similar numbers in every cheap pattern, their rows are similar, so they land close together. Communities become clusters of points. SpectraFuse normalises each row and runs k-means to label the communities:

ẑi = zi‖zi‖,   community(i) = k-means(ẑ1, …, ẑn)spectral clustering

The coordinates come from the graph, not the text. Two chunks with no words in common, like “research is done by Helix Labs” and “Helix Labs is headquartered in Pune”, can still sit close together because the graph ties them.

5The same matrix spreads relevance

The Laplacian also describes how heat flows through the springs. Put relevance on one chunk and let it diffuse for time t (paper Eq. 17). Writing the start vector in the eigenvector basis shows why it stays inside communities:

s(t) = e−t Lsym s(0) = Σk e−t λk (uk⊤ s(0)) ukpaper Eq. 17

Each pattern fades at its own rate e−tλk. Expensive, high-λ patterns, the ones that distinguish chunks inside a group, vanish almost immediately, so relevance evens out within the group. Cheap patterns such as u2 fade slowly, so the difference between groups persists. The weaker the bridge, the smaller λ2 and the longer the groups stay separate.

Figure 2. Relevance diffusing over the graphClick a chunk to place the relevance there
0.0
0.30

Where the relevance is

Orion side
Bridge c4
Helix side

How much of each pattern is left

|e−tλk · uk(source)| for k = 1…7. Watch the high patterns die first.

Try this while presenting: keep the source on c2 and press Play. Relevance floods c1–c3 and c4 within a moment, then barely leaks to Helix. Drag the bridge to 1.0 and replay: far more crosses. Then move the source to c4 and show that the bridge chunk feeds both sides.

Exact heat diffusion needs every eigenvector, which is too expensive for a large corpus. The paper approximates it with a personalised-PageRank-style iteration over the candidate subgraph, which is what the package runs by default:

s(r+1) = β s + (1 − β) Ã s(r),   Ã = D−½ W D−½ = I − Lsympaper Eq. 18

β is the restart weight: each round, a share β of the relevance jumps back to the seeds, so it never drifts too far from what matched the query. The package's default, proximity_mode="hybrid", averages this with the spectral closeness of Section 6; "heat" evaluates Eq. 17 from the stored eigenpairs instead.

6How SpectraFuse uses all of this

The spectral machinery shows up in three places in the online pipeline.

Closeness during re-scoring

A candidate gets a proximity bonus for sitting close to the seeds in spectral space, averaged with the PageRank diffusion above, and blended into its score. Each coordinate is first damped by e−tλ, as in the heat kernel, so the smooth community-level directions count most:

z̃c = zc ⊙ e−tλ,   spectral(c) = ½ (1 + cos(z̃c, Σs ∈ S0 ρs z̃s))spectral closeness
s2(c) = (1 − α) r(c) + α · ½ (spectral(c) + PPR(c))re-scoring

ρs is each seed's share of the relevance (the PageRank restart vector), t is diffusion_t (1.0), PPR is scaled to 0…1, and α is alpha (0.3 by default). This is the package's default, proximity_mode="hybrid". The step-by-step explainer's teaching version scores closeness to the best single seed instead of the seeds' weighted centre.

Community coverage during selection

When packing the token budget, a chunk from a community that is not yet represented earns a bonus. This is what let the Helix chunk beat three near-duplicate Orion chunks in the main demo:

ClusterGain(c | C) = c1 + |{ j ∈ C : community(j) = community(c) }|part of paper Eq. 21

Spreading relevance to bridges

The diffusion of Section 5 lets a chunk that doesn’t match the query gain relevance from a strongly connected chunk that does, which is how bridge evidence gets noticed.

Figure 3. The real 12-chunk SpectraFuse demo, placed at (u2, u3)Hover a chunk for its text
Same idea on a graph built from all six signals, the one used by the step-by-step explainer. Helix (c5–c7) and Nova (c8–c9) form their own communities, and the Orion team chunk c4 sits between Orion and Helix, exactly like the toy graph. Eigenvalues:

7How the visuals are implemented

Both figures run entirely in your browser with plain JavaScript and SVG: no libraries. The functions below are the exact code this page runs; the page shows the same text it executes. Each step mirrors a line of the Python implementation.

Step 1: build the normalised Laplacian

Fill W from the edge list, sum each row to get the degrees, and apply Lsym = I − D−½WD−½ entry by entry.

JavaScript, exactly as this page runs itbuildLsym()
function buildLsym(edges, n) {
  const W = Array.from({ length: n }, () => Array(n).fill(0));
  for (const [i, j, w] of edges) { W[i][j] = w; W[j][i] = w; }      // symmetric weights
  const d = W.map(row => row.reduce((a, b) => a + b, 0));          // degree d_i = Σ_j W_ij
  const L = W.map((row, i) => row.map((w, j) =>
    (i === j ? 1 : 0) - w / Math.sqrt(d[i] * d[j])));               // L_sym = I − D^−½ W D^−½
  return { W, d, L };
}

Step 2: find eigenvalues and eigenvectors (Jacobi rotations)

For a small symmetric matrix, the classic Jacobi method is short and exact enough. It repeatedly picks an off-diagonal entry and applies a 2 × 2 rotation that zeroes it. The matrix converges to a diagonal of eigenvalues, and the product of all rotations, V, holds the eigenvectors as columns. In Python this whole step is one call, np.linalg.eigh.

JavaScript, exactly as this page runs itjacobiEigen()
function jacobiEigen(M) {
  const n = M.length, A = M.map(r => r.slice());
  const V = A.map((_, i) => A.map((_, j) => (i === j ? 1 : 0)));    // accumulates the rotations
  for (let sweep = 0; sweep < 100; sweep++) {
    let off = 0;                                                    // size of what is left off the diagonal
    for (let p = 0; p < n; p++) for (let q = p + 1; q < n; q++) off += A[p][q] ** 2;
    if (off < 1e-20) break;
    for (let p = 0; p < n; p++) for (let q = p + 1; q < n; q++) {
      if (Math.abs(A[p][q]) < 1e-16) continue;
      const theta = (A[q][q] - A[p][p]) / (2 * A[p][q]);            // rotation angle that zeroes A[p][q]
      const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
      const c = 1 / Math.sqrt(t * t + 1), s = t * c;
      for (let k = 0; k < n; k++) { const a = A[k][p], b = A[k][q]; A[k][p] = c * a - s * b; A[k][q] = s * a + c * b; }
      for (let k = 0; k < n; k++) { const a = A[p][k], b = A[q][k]; A[p][k] = c * a - s * b; A[q][k] = s * a + c * b; }
      for (let k = 0; k < n; k++) { const a = V[k][p], b = V[k][q]; V[k][p] = c * a - s * b; V[k][q] = s * a + c * b; }
    }
  }
  const order = A.map((_, i) => i).sort((a, b) => A[a][a] - A[b][b]);   // ascending eigenvalues
  return { values: order.map(i => Math.max(0, A[i][i])),              // λ_1 … λ_n
           vectors: order.map(i => V.map(row => row[i])) };           // u_k = column k of V
}

Step 3: fix each eigenvector’s sign

An eigenvector is only defined up to sign: u and −u are equally valid. Without a convention, colours and positions would flip randomly as you move the slider, so each vector is flipped to make its largest entry positive.

JavaScript, exactly as this page runs itfixSign()
function fixSign(v) {
  let big = 0;
  for (const x of v) if (Math.abs(x) > Math.abs(big)) big = x;
  return big < 0 ? v.map(x => -x) : v;                              // largest entry positive
}

Step 4: the spring-energy bars

Each bar in Figure 1 is one term of the energy formula. Because the eigenvectors have length 1, the bars add up to λk, which is a live check that the decomposition is right.

JavaScript, exactly as this page runs itedgeCosts()
function edgeCosts(edges, d, u) {
  // one term of xᵀ L_sym x per edge: stiffness × stretch²
  return edges.map(([i, j, w]) => w * (u[i] / Math.sqrt(d[i]) - u[j] / Math.sqrt(d[j])) ** 2);
}

Step 5: the wobble and the map

Every frame, each node is displaced vertically by its eigenvector value times a sine wave. Cheaper patterns wobble more slowly, a visual stand-in for low frequency. In map view the node simply moves to (u2(i), u3(i)), scaled to the canvas, and glides there by easing a little of the remaining distance each frame.

JavaScript, exactly as this page runs itwobbleY()
function wobbleY(restY, u, i, lambda, time, amplitude) {
  const omega = 1.4 + 3 * lambda;                                   // cheaper pattern, slower wobble
  return restY - amplitude * u[i] * Math.sin(omega * time);
}
JavaScript, exactly as this page runs itmapXY()
function mapXY(u2, u3, i) {
  return [340 + 520 * u2[i], 170 - 165 * u3[i]];                    // (u₂(i), u₃(i)) on the 680 × 340 canvas
}

Step 6: heat diffusion

Figure 2 evaluates the eigenvector form of Eq. 17 directly. The source is a single chunk, so uk⊤s(0) is just uk(source). Shares are the clipped values divided by their total.

JavaScript, exactly as this page runs itheat()
function heat(values, vectors, src, t) {
  // s(t) = Σ_k e^(−t λ_k) · (u_kᵀ s(0)) · u_k, with s(0) = 1 on the source chunk
  const n = vectors.length, h = Array(n).fill(0);
  for (let k = 0; k < n; k++) {
    const coef = Math.exp(-t * values[k]) * vectors[k][src];
    for (let i = 0; i < n; i++) h[i] += coef * vectors[k][i];
  }
  return h;
}

The same thing in Python

Everything above fits in a few lines of numpy. This prints λ2 = 0.066 and the same diffusion numbers as Figure 2.

Python with numpyspectral_toy.py
import numpy as np

edges = [(0,1,1), (1,2,1), (0,2,1), (3,1,.8), (3,2,.8), (3,5,.3), (4,5,1), (5,6,1), (4,6,1)]
W = np.zeros((7, 7))
for i, j, w in edges: W[i, j] = W[j, i] = w
d = W.sum(1)                                   # degrees
L_sym = np.eye(7) - W / np.sqrt(np.outer(d, d))  # I − D^−½ W D^−½
lam, U = np.linalg.eigh(L_sym)                 # λ ascending; U[:, k] is u_(k+1)
z = U[:, 1:3]                                  # spectral coordinates (u₂, u₃) per chunk
s0 = np.eye(7)[1]                              # all relevance on c2
heat = U @ np.diag(np.exp(-2.0 * lam)) @ U.T @ s0   # Eq. 17 at t = 2
print(lam.round(3))                            # [0. 0.066 0.986 1.357 1.454 1.5 1.637]

And this is how the package does it. The spectral coordinates are computed once, at ingestion (above 2,000 chunks a sparse solver from the scale extra gives the same eigenvectors); the proximity scores are computed for every question.

src/spectrafuse/utils/spectral.py, lines 21–36normalized_laplacian(), spectral_coords()
def normalized_laplacian(matrix):
    """L_sym = I - D^-1/2 W D^-1/2 (paper Eq. 11)."""
    scale = inverse_sqrt(matrix.sum(axis=1))
    return np.eye(len(matrix)) - scale[:, None] * matrix * scale[None, :]

def spectral_coords(laplacian, dims):
    """Eigenvectors 2 .. dims+1 of L_sym (the first one is trivial), one row per chunk.

    Returns (coords, eigenvalues). Small graphs give fewer than 'dims' columns.
    """
    values, vectors = np.linalg.eigh(laplacian)  # sorted from smallest to largest
    used = min(dims, len(values) - 1)
    if used <= 0:
        return np.zeros((len(values), 0)), values[:0]
    return vectors[:, 1:used + 1], values[1:used + 1]
src/spectrafuse/utils/diffusion.py, lines 34–56personalized_pagerank(), spectral_closeness()
def personalized_pagerank(matrix, restart, beta=0.15, steps=30):
    """Eq. 18: s <- beta * restart + (1 - beta) * A_tilde @ s, with A_tilde = D^-1/2 A D^-1/2."""
    scale = inverse_sqrt(matrix.sum(axis=1))
    a_tilde = scale[:, None] * matrix * scale[None, :]
    scores = restart.copy()
    for _ in range(steps):
        scores = beta * restart + (1 - beta) * a_tilde @ scores
    return scores

def spectral_closeness(coords, eigenvalues, candidates, restart, t=1.0):
    """Cosine (mapped to 0..1) between each candidate and the seeds' weighted centre.

    Each spectral coordinate is scaled by exp(-t * eigenvalue), as in the heat kernel
    exp(-t L) of Eq. 17, so smooth community-level directions count most.
    """
    if coords.shape[1] == 0:
        return np.full(len(candidates), 0.5)
    points = coords[candidates] * np.exp(-t * np.asarray(eigenvalues))[None, :]
    centre = restart @ points  # seeds weighted by relevance (restart is 0 for non-seeds)
    norms = np.linalg.norm(points, axis=1) * np.linalg.norm(centre)
    cosine = np.divide(points @ centre, norms, out=np.zeros(len(candidates)), where=norms > 0)
    return (cosine + 1) / 2

8Explaining it to others in five minutes

  1. Start with the problem. “Embeddings tell us what a chunk says. We also want to know which chunks belong together, even when they use different words.” Show the edge table in Section 1.
  2. Springs. “Every link is a spring; stronger link, stiffer spring.” Show the energy formula and say it in words: stiffness times stretch squared.
  3. Pattern 1 in Figure 1. “Everything moves together, nothing stretches, energy zero. It tells us nothing, so SpectraFuse skips it.”
  4. Pattern 2. “The cheapest way to make chunks disagree: the two groups swing opposite.” Point at the gold bar: most of the energy is on the weak bridge, so that is where the split falls.
  5. Drag the bridge slider. “Weaker link, smaller λ₂, cleaner split.” Mention the eigengap rule.
  6. Spectral map. “Use each chunk’s numbers in the cheap patterns as coordinates. Groups become clusters; the bridge sits between them.” Then show Figure 3 on the real corpus.
  7. Heat in Figure 2. Press Play from c2. “Relevance spreads fast inside a group and slowly across weak links. High patterns die first.”
  8. Tie it back. Section 6: the coordinates give closeness and communities for selection; the diffusion spreads relevance to bridge chunks.