Skip to content
Roshan Chhetri
All work

Threadly

Semantic search over an infinite note canvas

role
Solo, design through deploy
when
2025
loc
3,066
files
50
  • Next.js 16
  • React 19
  • TypeScript
  • Tailwind v4
  • Supabase
  • Postgres
  • pgvector
  • OpenAI embeddings
  • Zustand

By the numbers

text-embedding-3-small
embedding model, one vector per note
top 5
returned from a match_notes pgvector query
match_count 6, self dropped, score = 1 - distance
80%
expected note in top results
hand-built query set, not reproducible from the public repo · self-reported

The problem with a notes app

Every notes app makes you do the filing. Tags, folders, backlinks, all of it manual, and all of it work you do now for a benefit you might get later. Most people stop filing within a week, and then the notes are just a pile.

Threadly does the filing with embeddings. Write a note, and it finds the other notes it belongs near. No tags, no folders, nothing to maintain.

Retrieval, concretely

Every note is embedded on write with OpenAI text-embedding-3-small, and the vector is stored on the row in Postgres with pgvector.

The part I like is what happens on read. To find notes related to note N, the endpoint does not re-embed anything. It reads N's stored embedding straight off the row and uses that as the query vector. Finding neighbours costs one database round trip and zero API calls.

The nearest-neighbour search is a Postgres function called over RPC:

const { data: neighbors } = await supabase.rpc("match_notes", {
  query_embedding: embedding,
  match_count: 6,
});
 
const results = (neighbors ?? [])
  .filter((r) => r.id !== id)                 // a note is always its own nearest neighbour
  .map((r) => ({ id: r.id, score: 1 - r.distance, distance: r.distance }))
  .slice(0, 5);
on writeon readnote texttext-embedding-3-smallnotes row + vectorpostgres · pgvectorsame vectoropen note Nread its stored vectormatch_notes(k = 6)drop self, keep 5a note is always its own nearest neighbour,so asking for five would have returned fourscore = 1 − distance
Two lanes. The read path never calls the embedding API, because the note it is searching from was already embedded on the way in.

match_count is 6 rather than 5 on purpose. A note is always its own closest match at distance 0, so asking for six and dropping self leaves exactly five real neighbours. Asking for five would have quietly returned four.

Distance is converted to a similarity score with 1 - distance before it crosses the API boundary, so the client never has to know that smaller means closer.

The canvas

Notes live on an infinite canvas rather than in a list, which meant writing the camera by hand: a { x, y } offset plus a scale, and conversion in both directions between screen space and world space.

const worldX = (screenX - camera.x) / scale;

Pinch-to-zoom is Math.hypot on the delta between two active touch points, compared frame to frame. Two fingers moving apart is a ratio greater than one, which becomes the scale multiplier.

Notes connect to each other with edges, and the connection endpoints are typed rather than positional:

type AnchorSide = "top" | "right" | "bottom" | "left";

Persisting from_side and to_side alongside from_id and to_id means an edge remembers which face of the note it left from. Move the note and the curve re-renders from the correct side instead of snapping to the nearest one and flipping around as you drag.

Things I did not skip

Auth runs through Supabase with Google OAuth and email. The route guard calls getUser() rather than getSession(). getSession() reads the JWT out of the cookie and believes it; getUser() revalidates against the auth server. The second one is a network call on every guarded request and it is the correct choice.

Response headers are set rather than defaulted: HSTS with preload and a two year max-age, CSP frame-ancestors 'none', X-Frame-Options: DENY, cross-origin opener policy set to same-origin, and a permissions policy that denies camera, microphone and geolocation outright. None of it is interesting, all of it is the difference between a side project and something you would put a login on.

What I would change

The embedding happens synchronously on write, which is fine at the current size and would not be at ten times it. The right shape is a queue: write the note immediately, embed out of band, backfill the vector. Right now a slow OpenAI response is a slow save.

There is also no index tuning on the vector column yet. At this corpus size a sequential scan is genuinely faster than an approximate index, so adding one now would make it slower, not faster. It becomes worth doing somewhere in the tens of thousands of notes.