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.
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.
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.
| Edges | Weight Wij | Meaning |
|---|---|---|
| c1–c2, c2–c3, c1–c3 | 1.0 | Orion chunks, tightly linked |
| c4–c2, c4–c3 | 0.8 | The team chunk belongs with Orion |
| c4–c6 | 0.3 (adjustable) | The bridge: shared entity “Helix Labs” |
| c5–c6, c6–c7, c5–c7 | 1.0 | Helix chunks, tightly linked |
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:
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:
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:
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.
The “cheapest ways to assign numbers” are the eigenvectors of Lsym, ordered by their eigenvalues:
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:
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.
Each bar is Wij(ui/√di − uj/√dj)² for one edge. The bars add up to λ exactly. The gold bar is the bridge.
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.
Stack the first few non-trivial eigenvectors as columns, and read the table row by row. Each chunk’s row is its spectral coordinate:
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:
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.
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:
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.
|e−tλk · uk(source)| for k = 1…7. Watch the high patterns die first.
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:
β 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.
The spectral machinery shows up in three places in the online pipeline.
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:
ρ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.
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:
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.
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.
Fill W from the edge list, sum each row to get the degrees, and apply Lsym = I − D−½WD−½ entry by entry.
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 }; }
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.
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 }
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.
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 }
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.
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); }
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.
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); }
function mapXY(u2, u3, i) { return [340 + 520 * u2[i], 170 - 165 * u3[i]]; // (u₂(i), u₃(i)) on the 680 × 340 canvas }
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.
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; }
Everything above fits in a few lines of numpy. This prints λ2 = 0.066 and the same diffusion numbers as Figure 2.
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.
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]
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