Note Graph is Ready

Note Graph is a Joplin plugin that turns a vault into an interactive graph wired together by links, tags, and semantic similarity. When I left off, the graph had a real problem. As soon as the AI similarity edges started rendering on a live vault, everything collapsed into a hairball. A note with a lot of connections became a dense blob where you could not tell one cluster from another, and the whole thing was close to useless for actually finding anything.

The work after that point came in two waves. First, make the graph readable again. Second, make sure it stays readable and correct as the vault changes.

Making the hairball readable

The fix for the hairball was already written into the proposal, where my mentor and I agreed to do community detection and centrality scoring in Pass A. The plan always called for node color to reflect community and node size to reflect importance. The hairball appeared because semantic edges went live before those two pieces were implemented, so every node still looked the same. Finishing them is what made the graph readable.

The first is community detection. The plugin runs the Louvain clustering algorithm over the assembled graph using the `graphology` and `graphology-communities-louvain` libraries, as the proposal specified. Notes that are actually connected to each other end up grouped, and every note is colored by the group it belongs to. Even in a dense graph, you can now spot at a glance which notes form a real cluster.

The second is centrality. Node size scales with how connected a note is, mapped to the 1 to 10 degree-centrality scale from the proposal. Most notes have a handful of connections while a couple of hubs have far more, so a plain min-max scale would squeeze everyone except the hubs down to the smallest size. Instead the sizes are log-compressed, so the difference between a note with 2 links and a note with 20 links stays visible without the hubs dominating the whole canvas.

Louvain uses randomness internally, and the library's default uses `Math.random`, which meant colors reshuffled on every rebuild. I swapped in a seeded random number generator so the same graph always produces the same result. I also made cluster numbering deterministic and size-ordered, so cluster 0 is always the largest. The proposal also listed a keyword fallback for sparse vaults, grouping notes that share keywords and direct links, so I built that too using a stopword list and a union-find structure. Louvain checks when its own result is degenerate, meaning it just produced a pile of singletons or one giant bucket, and switches to the keyword fallback when that is more useful.

Louvain detects communities but does not name them, so clusters still show up as `Cluster (N)` with a color and nothing more. Naming them from the most frequent terms in each group was listed in the proposal and is still left to do.

Keeping the graph correct as you edit

The next problem was rebuilds. Every edit tore down the whole graph and rebuilt it from scratch, which is slow, makes nodes jump around, and wastes AI work that was already done. The fix was incremental updates, worked out together with my mentor.

The plugin now listens to Joplin workspace events for note changed, note selected, and sync completed. Changes are buffered and flushed after a short one second coalescing window, so a burst of edits does not trigger a rebuild for every keystroke. It detects what changed in two ways. It reads Joplin's `/events` API with a saved cursor to catch new, updated, and deleted notes. When AI is on, it also sweeps the embeddings endpoint with its own saved cursor to catch notes that got re-embedded, throttled so it does not rescan more than once every five minutes.

The built graph is now cached on disk in a local SQLite database, so reopening the panel loads instantly instead of recomputing everything. On top of that, a diffing step compares the old graph to the new one and works out exactly which nodes and edges were added, changed, or removed. The webview applies just that patch instead of rebuilding, so notes you did not touch keep their position on screen.

This is where things got fiddly. Tag edges needed a stable, order-independent identity so they would not flicker during partial updates. Edges needed explicit IDs so the diffing step could match them between rebuilds. When an update fails, the code retries with backoff up to a limit, and if it still cannot apply the delta, it falls back to a full reload rather than leaving the panel silently out of sync. None of that shows up in a screenshot, but it is what makes the feature feel solid instead of flaky.

Explaining connections with a second AI pass

A graph can now show that two notes are related, but not why. The optional second stage, which I called Pass B, fills that gap. It is off by default and only runs on connections the graph already found, so it never scans the whole vault blindly.

When enabled, Pass B asks Joplin's built-in AI chat to do two things: give each note a short category label, and explain in plain words why two related notes are connected. Categories appear as a badge in note tooltips, and the explanation shows when you hover a connection line.

The hard part was making the AI calls safe to rely on. Enrichment runs in small batches of four edges at a time, with up to four attempts per batch and a delay between retries. If a response is not valid JSON or fails schema validation, it retries, then gives up on that batch rather than crashing the whole run. Labels are cached in memory and in the local database keyed by the note's updated time, so unchanged notes are never re-asked. There is also a Retry AI labels setting to manually force a re-check. If Joplin's AI is not available at all, the plugin just skips Pass B and shows the graph without labels.

Control for real day to day use

The last feature push was about letting people actually steer the graph. You can now choose which notebooks appear in it: all, the current notebook, or a specific selection, with the scope expanding to include child notebooks. Focus mode dims everything outside a note's neighborhood so you can isolate one part of your vault. A confidence slider hides low-confidence semantic edges. There is a layout switcher between fCoSE and a hierarchical layout, plus community grouping and a category filter. LLM labels now carry over when you switch notebooks, and popular tags link through a capped ring instead of being dropped when too many notes share them. Along the way I fixed a couple of real bugs, including notes edited mid-labeling missing their labels and a scope-change error.

Documentation, tests, and shipping

Before wrapping up I wrote the full documentation set under `docs/`, covering architecture, the data pipeline, the similarity engine, the graph model, LLM enrichment, incremental updates, caching, settings, development setup, and troubleshooting. The final task was filling in the manifest with the plugin name, description, keywords, and categories and introducing golden-set tests for `AnalysisController` and `GraphPipeline`. Those tests pin the pipeline output against recorded snapshots, so any future change that silently changes what the graph produces fails loudly. Everything is now merged and the plugin sits at version 1.0.0.

What my mentor flagged

In the review of the final merge, my mentor approved the work and also wrote down three follow-up items we discussed during testing.

First, there is a graph sync issue on larger vaults to investigate, specifically whether missed or out-of-order incremental `graph-patch` updates can leave the panel out of sync versus a full reload. Second, there is a temporary grey and unresponsive state when toggling Group or Focus on larger graphs, tied to the synchronous fCoSE layout reruns and repeated clicks while a layout is still running. Third, cluster naming is a known limitation; the keyword extraction already in `LouvainDetector` could give clusters real names instead of `Cluster (N)`. That third one is post-GSoC work, not blocking.

I will be working on these next.

Wrapping up

The project went from an empty template in May to a version 1.0.0 plugin in August. It started as links and tags, grew a semantic layer on Joplin's own AI, hit a hairball, then solved it with communities, centrality, incremental updates, an optional explanation pass, scoping, focus mode, confidence filtering, documentation, and snapshot tests.

One design rule stayed consistent through all of it: the plugin should never block and never break. When AI is off or unavailable, the graph still works. When an AI call fails, it retries and then degrades gracefully. When an update cannot be applied, it falls back to a full reload.

Thank you to my mentor for the reviews and the follow-up list. Thank you to the Joplin team and the GSoC community. The code is at [github.com/joplin/plugin-note-graph](GitHub - joplin/plugin-note-graph · GitHub) and I will keep fixing it.

Preview:




Is there any documentation on how to use this plugin? I thought I would try to check how it maps notes using the embeddings but I can't get anything working:

  • When it starts, it's on "All notebooks", so it displays thousands of little dots, one for each note, but without connections between them (thankfully, as I think building the graph would have frozen the app)

  • So I selected "Current notebook" to filter this, but it only shows "No graph data received"

  • So I thought I would select a specific notebook, but there's just a list of all my notebooks in random order so it's impossible to find the one I want. If I try to pick one anyway it still hows "No graph data received"

  • I decided to go back to "All notebooks" and try to make it work from there, but also "No graph data received"

So far, a least for me, the plugin simply doesn't do anything, so I don't know if I missed something or if there's a problem.

I too was a bit confused. I have a TON of links in my Joplin vault but most attempts to get something graphed resulted in a blank canvas.

But one notebook tree in my vault contains notes that link to other notes within that tree. It was when I selected that top-level notebook that connections appeared. Any target links that are outside of the current notebook are not followed and displayed.

This particular notebook in my vault captures personal anecdotes over the course of my life. People, places, things, and events are linked to each other. The plug-in seems to work great for this use-case, but nearly the rest of my vault links to notes that aren't in the same notebook.

I don't have many links actually, but I wanted to test this advertised feature:

  • Semantic connections. With Joplin AI enabled, the plugin embeds your notes and adds edges between notes that are related in content even when nothing links them. Tunable threshold and edge count.

Joplin AI and embeddings are enabled but nothing shows up (or everything shows up but without edges)

Hi, I checked everything on my end and it works fine for me, even with a large number of notes. So I'd like to narrow down the cause. By any chance, does your vault have more than 5000 notes? The plugin currently fetches notes with a hard cap of 5000.

It has 12k notes

Got it, I'm working on the fix for it, I was not expecting someone having so many notes.

Compared to some users here that's in fact a relatively small number of notes. But if you have a hard cap anyway, it shouldn't matter how many notes?

I think it makes sense to remove the hard cap completely.