Local-first bookmark retrieval

Find the bookmark you can only half remember.

You saved it. You remember roughly what it was about, or why you wanted it, or what else you were reading that afternoon — just not the title. facetmark indexes all three of those and searches them together, against one SQLite file on your own machine.

Python 3.10+Tests 1,514Licence MITStorage 1 SQLite fileUpload none
facetmark demo --size 60
$ facetmark search "sqlite-vec latency shard recall"
// content-style query — you remember the words
 
1Why chromadb changes the recall story0.0776
2sqlite-vec: notes on embedding0.0829
3hnswlib-5: notes on index0.0768
4qdrant-6: notes on persistence0.0767
5Evaluating pgvector-6 for filter0.0777
 
5 hits · 17.0 ms · target at rank 2
Real output from facetmark demo, which builds a 60-page synthetic library offline. Provider is mock, so this is a plumbing check, not a quality measurement — the mock hashes text into vectors. The score column is not sorted because the rank comes from stage E and the score is the fusion score, which stage E deliberately does not overwrite.

The problem

Three ways you look for a saved page

A folder tree answers the first one. It has nothing to say about the other two, which is why you end up scrolling history. These are the three query types the evaluation is built on, with the Recall@5 each one actually scored on a real 1,700-bookmark library.

content-style

You remember the words

The page said sqlite-vec and shard and you want it back. Any decent index does this.

“sqlite-vec latency shard recall”

0.959
Recall@5
vague

You remember the idea, not the words

You know what problem it solved. You never knew the product name, or you have forgotten it. Folder names are no help here; this is what the content embedding is for.

“that thing about keeping vectors next to the rest of my data without another server”

0.706
Recall@5
episodic

You remember when, and what was beside it

“The same afternoon I was reading about qdrant.” facetmark reconstructs saving sessions and a link graph to answer this — and it is still the weak spot. Published because it is true, not because it flatters.

“the other thing I saved around the same time as qdrant”

0.279
Recall@5

479 queries, one real library, config A. Full protocol and the rest of the table on the measured page.

Quickstart

Four commands

On a Chromium-family browser — Chrome, Edge, Brave, Vivaldi, Chromium, Opera — you do not even have to export first. facetmark import with no argument finds the live profile and reads it.

shell
pip install facetmark

facetmark import                  # finds your browser profile; read-only
facetmark index                   # fetch, enrich, embed, sessions, edges
facetmark search "that post about keeping vectors in sqlite"

No API key handy? facetmark demo builds a synthetic 60-page library offline and searches it, so you can see the shape of the output before committing anything.

  1. Install. Python 3.10 or newer. The only heavy optional dependency is sentence-transformers, and only if you want local embeddings.
  2. Import. Nothing is ever written back to your browser. Firefox and Safari are not Chromium-family, so those two need a one-off HTML export — how to do that.
  3. Point it at a model. Any OpenAI-compatible endpoint, or a local embedding model and no key at all. Without a model you still get lexical search and the session graph.
  4. Index. Idempotent. Run it again after adding bookmarks and it only does the new work.
  5. Search. Or run facetmark serve and use the browser extension, the HTTP API, or an MCP client.

How it works

Four facets. One of them is on by default.

facetmark builds four independent indexes over the same library and can fuse them with reciprocal rank fusion. Then it measured the fusion and found it worse than the single best facet, so the shipped default runs one facet. The other three are still there, still tested, one flag away — they are just not on, because the numbers said not to.

FacetWhat it indexesAnswersDefault
Lexical
two FTS5 indexes
Character trigrams and word segments of the title, URL and body.Exact strings, identifiers, code, error messages, and Chinese text that has no spaces to tokenise on.off
cost 5.4pp when fused
Content
dense vector
An embedding of the extracted page body, not the title.Paraphrase. The idea you remember when the words are gone.on
W1 winner, 0.643
Intent
generated queries
Candidate questions a model writes for the page, kept only if searching them actually retrieves the page back.Why you would come looking, phrased the way you would phrase it later.off
38% of intents plausible
Context
sessions and graph
Save-session clustering, domain structure, and a link graph over the library.“What did I save around that one?”graph on gate off
+2.09pp / −18.83pp

Every one of those four verdicts links to a protocol, a query set and a confidence interval on the measured page.

The pipeline

From a query to a ranked list

The coloured stages are what runs in the shipped default profile. The grey ones are built, tested and switched off. Every indexing stage is idempotent and fingerprinted, so facetmark index re-runs only the work whose input changed.

your queryone line, typedunderstandlanguage, intentlexical · trigramFTS5 over charactersoff by defaultlexical · segmentsFTS5 over wordsoff by defaultcontentvector over the page bodyintentvectors over generated questionsoff by defaultRRFk = 60post-stagescontext gatecold layerrerankerhitsranked1-hop graphsession + semantic edgeslinkedseparate groupshipped default pathbuilt, wired, off by default
your queryone line, typed
understandlanguage, intent

four facets, in parallelone of them is on by default

lexical · trigramFTS5 over charactersoff by default
lexical · segmentsFTS5 over wordsoff by default
contentvector over the page body
intentvectors over generated questionsoff by default
RRFk = 60
post-stagescontext gate · cold layer · reranker
hitsranked

branches off the fusion step

1-hop graphsession + semantic edges
linkedseparate group

shipped default pathbuilt, wired, off by default

Indexing

bookmarkfetchcontentenrich (summary, topics, entities, key points) → embedintents → filter → sessionsedges.

Fingerprints

Enrichment is keyed on the body hash; embedding is keyed on the reconstructed embed text, so a vector that no longer matches its text is detected rather than trusted. --force ignores both.

Graph expansion

One hop out from the fused hits, returned as a separate group rather than mixed into the ranking. Measured at +2.09pp, 10 wins and 0 losses, 9 ms.

The page you open

A search page, at 127.0.0.1:8787/app

facetmark serve prints a URL. Open it and you get the search box, the same markers on a result row that the extension uses, and a second view that tells you what your index actually contains. Nothing to install and nothing to build — the page ships inside the Python package and is served by the same process.

the facetmark search page showing ranked results and a separate group of pages saved in the same sittingthe same search page in dark mode
Search. The first paint is lexical and costs no model call; the ranked answer replaces it when it arrives. Pages you saved in the same sitting arrive as their own group rather than shuffled into the ranking. This frame follows the theme of the page you are reading.

It pairs itself

The token is fetched from a route that answers only when the caller and the address in the request are both loopback, so on your own machine there is nothing to copy. Anywhere else the page asks you to paste it once.

It says what is missing

An empty library prints the import command. Bookmarks with no vectors print facetmark index. A search with no hits and a full fetch queue tells you that, instead of showing you an empty list and letting you guess.

English and 中文

One switch in the header, remembered between visits. Light, dark, or whatever your system is set to. / focuses the box, the arrow keys walk the results, Esc clears.

Start from nothing →

In the browser

An extension that talks to localhost and nothing else

Manifest V3. Host permissions are http://127.0.0.1:8787/* and http://localhost:8787/*. It reaches your own machine, pairs with a token, and never writes to your browser's bookmark store.

facetmark popup showing grouped resultsthe same popup in dark mode
Popup. Every result carries the facets that matched, and pages you saved in the same session arrive as their own group rather than shuffled into the ranking. This frame follows the theme of the page you are reading.
facetmark options pagethe same options page in dark mode
Options. Endpoint, pairing token, an optional second channel, and a pause switch. Four fields, no account.

What the markers on a row mean

  • aboutthe content facet matched: a vector over the page body. The one facet that is on by default.
  • asked asthe intent facet matched: vectors over questions generated for the page. Off by default.
  • wordsthe lexical · segments facet matched: FTS5 over words. Off by default.
  • substringthe lexical · trigram facet matched: FTS5 over characters. Off by default.
  • coldthe link looks dead, so the row is demoted rather than removed. facetmark health says why.
  • saved around thesea second group, from one hop over session and semantic edges. Never mixed into the ranking above it.

These are UI previews rendered against mock data, not screenshots of a real library — a real one would put somebody's browsing history on a public page.

Evidence

Four features were measured and lost. They are off.

The interesting part of this project is not the features that worked. It is the ones that were built, pre-registered, measured, and then turned off — including one that had already shipped.

0.643
Recall@5 on 479 real queries, one facet
−5.4pp
what turning on all four facets cost
−18.83pp
what the shipped episodic gate cost

W1 · Recall@5 by rung, 479 queries, one real library

A content vector only
0.643
B + two lexical facets
0.589
C all four facets
0.635
D + context + graph
0.639

Three criteria were registered before the run. All three failed. Fusion cost 5.4 percentage points of Recall@5 and made queries 3.5× slower — 148 ms at p50 became 526 ms. The four-facet default was withdrawn the same day.

Two things did survive that run and are shipped: graph expansion as a separate result group (+2.09pp, 10 wins, 0 losses, p=0.0019) and the reranker on Recall@1 (+4.80pp, CI95 [+1.46, +8.35]).

Then there is the episodic gate. It won its holdout (+3.09pp, 19 wins, 0 losses, p=3.8e−6) and shipped. A 361-query probe set built afterwards to ask what it does when it fires on the wrong query answered −18.83pp, 3 wins against 71 losses. The default was reverted.

Read all nine results →

Interfaces

Six ways in, one index

The local page

facetmark serve hosts a search page at /app. Search and a library overview, English or Chinese, light or dark. The only interface that needs nothing installed beyond facetmark itself.

What is on it →

Command line

Sixteen commands. search takes --explain to print which facet matched, and --config to run any ablation rung by name.

Command reference →

HTTP API

facetmark serve binds 127.0.0.1:8787. Twenty-seven routes. Four are open — the root, health, and the two the local page needs to load itself; everything that touches the library requires a pairing token.

Routes and auth →

MCP server

facetmark mcp speaks MCP over stdio. Nine tools and three resources, so Claude Desktop can search your library and read a saving session.

Client config →

Browser extension

MV3. Omnibox keyword fm, Ctrl+Shift+K, one-click save with a local indexing queue.

Install and pair →

karakeep plugin

A search-provider plugin that puts facetmark behind karakeep's own search box. The wire contract is pinned by a replay test.

Wire it up →

Questions

The six that actually get asked

Does anything get uploaded?

Two things leave your machine, both of which you control. Page fetching goes to the sites you bookmarked. Enrichment and embedding go to whichever OpenAI-compatible endpoint you configured — that can be OpenAI, or a box on your desk.

Set FACETMARK_EMBED_BACKEND=local and leave FACETMARK_API_KEY empty and nothing beyond page fetching leaves the machine at all. There is no facetmark server to talk to. There is no account.

Will it touch my browser's bookmarks?

No. Import is a one-way read. The importer opens the profile's Bookmarks file or your exported HTML, reads it, and closes it. Nothing in the codebase writes to a browser profile.

Nothing is deleted on the facetmark side either. The decay layer demotes stale pages in the ranking; it never removes a row.

Can I use it with no LLM at all?

Yes, and it will be worse, and it will tell you so. Without a model you keep both lexical facets and the whole session and domain graph. You lose the content facet — the one that measured best — and the intent facet.

A middle path: run a local embedding model for the content facet and skip the chat model. You lose enrichment summaries and generated intents, keep paraphrase search.

What does indexing cost?

Dominated by enrichment: roughly one small chat call per page. On a cheap model, 1,700 pages costs cents. Embedding is cheaper still, and free if you run it locally.

Wall time is dominated by fetching, not by models. facetmark honours robots.txt and rate-limits itself per domain on purpose. --no-fetch indexes titles only and finishes in seconds.

Why is the default only using one facet?

Because four-facet fusion was measured on 479 real queries and came out 5.4 points of Recall@5 behind the content facet on its own, at 3.5× the latency.

The mechanism is written up: flat-weight RRF lets a coincidence on two weak facets (0.0279) outvote confidence on one strong facet (0.0164). The facets still exist and are still tested. --config C turns them all on if you want to see it for yourself.

Is this a product?

No. It is a tool with an evaluation harness attached, and the harness is the point. Every default that changed has a protocol, a pre-registered criterion and a confidence interval behind it, and four of those protocols killed the feature that motivated them.

The largest missing piece is stated on the measured page: every query set so far was written by the author, which is the one bias no amount of bootstrapping fixes.

Boundaries

What this thing refuses to do

  • Read-only on your browser

    Import never writes back. Your folder tree is yours.

  • Nothing is deleted

    The cold layer demotes. It does not remove rows, and facetmark health shows you what it considers dead and why.

  • Local first

    One SQLite file you can open with any SQLite browser. If you stop using facetmark your data is still readable.

  • Polite by default

    robots.txt is honoured, per-domain concurrency is capped at 2, there is a minimum interval between hits on one host, and the user agent says what it is.

  • No number without a protocol

    And no default change without a query set that was frozen before the run.

Start with four commands, or read the numbers first.

The guide covers install, all four browsers, both provider setups, the extension, MCP and the karakeep plugin. The measured page covers every retrieval claim in the project, including the ones that failed.