Why your notes app cannot find your note

Product

There is a specific moment when a note-taking app stops earning its keep. It is not when you run out of storage. It is the first time you know for certain that you wrote something down, you search for it, and the app tells you there are no results.

You did write it. The note is right there. You just searched for "that thing about pricing tiers" and the note says "three-band packaging, anchored on the middle SKU". Not one word in common.

This is the failure mode that gets worse as your archive gets more valuable. A hundred notes, you remember the wording. Ten thousand notes, you remember the idea and nothing else. Keyword search is at its weakest exactly when you need it most.

Notez runs two searches for every query and merges them. This post is about how that actually works — including the parts that are cruder than you might assume.

Branch one: keyword

The keyword branch is a plain SQL substring match. Your query becomes %query% and gets matched, case-insensitively, against:

  • the note title

  • the note's extracted plain text (the rich-text document flattened to text at save time)

  • custom property values — the labels of select and multi-select options, and the text of text-like properties

That last one matters more than it sounds. If you have a Client property with the option Acme, searching acme finds every note tagged with it, even if the word never appears in the body.

Up to 50 keyword results come back, ordered by most recently updated.

What this branch is good at: exact recall. Identifiers, error codes, names, a phrase you remember verbatim. If you type NOTE_MODIFIED, you want the note containing that literal string, ranked first, immediately.

What it is bad at: everything else. A substring match is not fuzzy — a typo in your query matches nothing. Synonyms match nothing. Concepts match nothing.

Branch two: semantic

The semantic branch converts your query into a vector and looks for note content that sits near it in that space.

Concretely: the query is embedded with OpenAI's text-embedding-3-small model into 1,536 dimensions, and Postgres (with pgvector) ranks stored note chunks by cosine distance from it. Up to 20 notes come back.

What this branch is good at: the case that broke keyword search. "Pricing tiers" and "three-band packaging" land close together in embedding space because the model was trained on enough text to know they are the same subject.

What it is bad at: precision. Vectors have no notion of "exactly this string". Search for a UUID and the semantic branch will confidently hand you something thematically adjacent and useless.

Neither branch is sufficient. That is the whole argument for running both.

What actually gets embedded

You cannot embed a whole note as one vector and expect good results. A 4,000-word meeting note averaged into a single point in space is near nothing in particular — it is a smear across every topic it touches.

So notes are chunked before embedding, and the chunking follows the document's own structure:

  1. The title is prepended to the content before anything else happens, so the title's wording is part of what gets indexed rather than a separate field nobody searches.

  2. Split on headings. Each heading starts a new section. A heading with no body underneath it still gets indexed on its own, because a heading carries meaning even when the section is empty.

  3. Split long sections on paragraphs. Anything over 2,000 characters is broken at paragraph boundaries rather than mid-sentence.

  4. Fold tiny chunks into their neighbours. Anything under 100 characters gets merged into the adjacent chunk instead of being dropped.

That last rule exists because of a bug. An earlier version discarded undersized chunks, which meant a note made of several short sections — exactly what a bulleted meeting note looks like — kept almost nothing. The note was indexed, technically. It was also unfindable.

Each stored chunk keeps its heading and its position in the document, so a semantic hit can tell you which part of a long note matched, not just that the note matched somewhere.

Because a long note produces many chunks, the query fetches three times the number of results it needs and then keeps only the best-scoring chunk per note. Without that, one sprawling note would occupy every slot in your results.

The two thresholds, and why they differ

Cosine distance runs from 0 (identical direction) upward. A result is only considered a match below a distance ceiling — and Notez uses two different ceilings for two different jobs.

Search uses 0.62. Strict.

Similar Notes uses 0.8. Loose.

The difference is not an oversight. Similar Notes is a browsing aid at the bottom of a note you are already reading; a loose match there costs you nothing but a glance. Search results are a claim that these notes answer your query.

At 0.8, search became useless in a way that took a while to notice. An arbitrary query matched almost any short note — short text produces embeddings that sit near the middle of everything — which meant a non-empty workspace could never render "No notes found". Every search returned something. Some of it was garbage, and there was no signal separating the two cases.

A search that can never say "nothing here" is not a search. It is a random note generator with extra steps.

How the two branches are merged

Here is the part that is simpler than you would guess: keyword results come first, in order. Then any semantic results not already in that list are appended.

That is it. There is no cross-encoder rerank, no score fusion, no reciprocal-rank blending. Both branches run in parallel, they are deduplicated by note id, and the keyword branch wins ties by virtue of going first.

We are stating this plainly because it has a consequence you will notice: a perfect semantic match can appear below a mediocre keyword match. If your query happens to be a common substring, the top of your results will be the substring hits.

Proper fusion ranking is a real improvement and it is not built. What is built is honest about what it is: a union, not a ranking.

Filters apply to both branches

Whatever you have filtered by — tags, custom property values, the Bin — constrains both searches, not just the keyword one. The semantic branch pushes those conditions down into the same query as the vector distance, so filtering never silently loses the semantic half of your results.

What happens when the semantic branch breaks

Embedding a query means a network call to an external model. Network calls fail.

When the semantic branch throws, the error is logged and that branch returns an empty list. Search does not error. You get keyword results, which is exactly what you would have gotten from any conventional notes app, and the page renders.

This is deliberate: the fancy half of a feature should never be able to take down the boring half. Degrading to plain keyword search is a bad day. Showing an error page because a vector database was slow is a worse one.

Similar Notes, briefly

Every note has a Similar Notes panel, and it works slightly differently from search: instead of embedding a query, it takes the note's own first chunk embedding and finds its nearest neighbours in the same workspace, excluding itself. Up to five results, at the looser 0.8 ceiling.

This is the feature that surfaces things you would never have searched for, because searching requires you to already suspect the connection exists.

Workspace scoping here is enforced by joining through the notes table rather than trusting the embedding rows, which have no workspace column of their own. Get that wrong and your work workspace starts recommending your personal notes.

How to actually search well

Given how the two branches divide the work:

  • For something you remember exactly — a name, an error string, an identifier — type it verbatim. The keyword branch will nail it.

  • For something you only remember the shape of, write a short phrase describing the idea, not the words. "Why we dropped the queue retry" works better than guessing at the original vocabulary.

  • Do not write long queries. Very long queries embed into an average of several topics and match nothing well.

  • Combine search with filters. Narrowing to one tag before searching turns a broad query into a precise one, and it constrains both branches.

  • If you find the note but not the passage, open it and check Similar Notes — that panel is often how you find the other three notes on the subject.

What is not built

To save you discovering it the hard way:

  • No fuzzy or typo-tolerant keyword matching. Misspell it and the keyword branch is silent.

  • No search operators — no quoted phrases, no AND/OR, no field-scoped syntax.

  • No relevance ranking across the two branches, as described above.

  • No saved searches. Filters live in the URL and can be bookmarked, but there is no named-view feature.

None of these are secrets, and none of them are the reason your note is hard to find. That reason is almost always the first one in this post: you searched for the words, and you wrote down the idea.

Two searches, unioned, fixes that specific problem. It is not magic. It is just the acknowledgement that human memory of your own writing is semantic, and a LIKE query is not.

Continue reading