Good things you saved.
Find them again.

Turn your browser bookmarks into a searchable personal library. Search by keywords, describe what you remember, or browse by date. Your library lives on the computer or server where you run facetmark.

Requires Python 3.10+Storage SQLiteLicense MIT
facetmark search with results, snippets and matching sources. Synthetic example bookmarks.the same search page in dark mode
Search, inspect sources, keep reading · Demo library

Start with what you remember

You do not need a perfectly organised folder tree. Start with keywords, add an embedding model for searches in your own words, and use dates or related pages to narrow things down.

A few words

Match words in titles, URLs and folders, including Chinese substrings.

“sqlite vector index”

An idea

With an embedding model, describe the content you need. Results depend on your indexed text and model.

“search that works without a connection”

A moment

Filter by save date, then explore other bookmarks saved in the same session.

“database articles saved last month”

The retrieval methods and their limits are documented. Read the evaluation methods and results.

Start small. Build your library from there.

Install with Python 3.10+, import your bookmarks, then open the app. The full tutorial covers models, indexing, local use and server access.

shell
python -m pip install facetmark
facetmark import
facetmark index --no-fetch
facetmark serve

Just exploring? Run facetmark demo for a synthetic offline library. No API key required.

  1. On your computer. Run the commands on the machine that holds your browser bookmarks.
  2. On a server. Export a bookmark file and transfer it to the server before importing.
  3. Check the flow first. --no-fetch skips page downloads; existing stored text can still be used.
  4. Add content. Configure a model and run facetmark index.
  5. Follow the complete tutorial →
See the offline demo output
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.

See what matched

Content vectors, generated questions, keywords and substrings offer different retrieval signals. With embeddings configured, the default uses content vectors, graph expansion and time decay. Compare other combinations in search options; lexical search remains available without a model.

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.

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

bookmark → fetch → content → enrich (summary, topics, entities, key points) → embed → intents → filter → sessions → edges.

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.

A place to pick up where you left off

Inspect result summaries and sources, synthesise an answer with citations, and check how much of your library has been fetched and indexed. The same interface works on desktop and mobile.

It speaks a filter language

domain:github.com, tag:work, added:<7d, -pinterest, sort:date — in the same box, with completion for the field names and the values that exist in your library. A query that is only filters is a browse: no model call at all.

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 →

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.

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 →

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

Twenty-one 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-nine 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 →

The six that actually get asked

Where does my data go?

The library stays on the machine running the service. Fetching contacts saved websites; online models receive the relevant text and queries. Importing through a server deployment transfers your file to that server.

A local embedding model computes on that machine after its files are downloaded. Online chat calls depend on your configuration. facetmark provides no hosted accounts or central storage service.

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?

Cost depends on page count, text length, model and provider pricing. Validate the connection and results with a small import before processing your full library.

Fetching also depends on site response times, access restrictions and per-domain rate limits. Later runs reuse unchanged stages; stored vectors and fetched page bodies are separate states.

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.

Who is it for?

For people who want to manage their own bookmark data, rediscover saved material and run a local or self-hosted tool. CLI, HTTP API and MCP interfaces connect it to existing workflows.

Public evaluation queries were written by the project author. Check the results on your own bookmarks; one dataset is not a quality guarantee.

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.

Put your saved pages to work

Start with a small set of bookmarks. Once your first search works, add models and integrations as you need them.