Guide

Install to first search in four commands, then everything else: all four browsers, both ways of reaching a model, the HTTP API, the browser extension, MCP, the karakeep plugin, every setting and every command.

01Install

Python 3.10 or newer, on Windows, macOS or Linux. The base install has no compiled machine-learning dependency; vector search comes from sqlite-vec, which is a SQLite extension.

shell
pip install facetmark
# or, if you use uv:
uv pip install facetmark

facetmark version

With local embeddings

Only needed if you want to embed pages on your own machine instead of through an endpoint. This pulls in PyTorch and sentence-transformers, which is a few hundred megabytes.

shell
pip install "facetmark[local]"

From source

shell
git clone https://github.com/88lin/facetmark
cd facetmark
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"

pytest -q                 # 1,514 tests
ruff check src tests scripts
Do not reformat the codebase

It is hand-formatted. ruff check is part of CI; ruff format is not, and running it produces a diff nobody wants to review.

Where your data lives

One directory, chosen per platform, holding one SQLite file. Override it with FACETMARK_DATA_DIR, or point any command at a specific file with --db.

PlatformDefault data directory
Windows%LOCALAPPDATA%\facetmark\
Linux / macOS~/.local/share/facetmark/
Any, if XDG_DATA_HOME is set$XDG_DATA_HOME/facetmark/

Inside it: facetmark.db and pairing-token.txt. That is the whole installation footprint. Deleting the directory uninstalls the data.

Try it with no key and no network

facetmark demo generates a synthetic library, indexes it with a deterministic offline provider, and runs three searches against it. It is how the terminal on the front page was recorded.

shell
facetmark demo --size 60

02Get your bookmarks in

Import is one-way and read-only. facetmark reads a browser profile or an exported file, and never writes to either.

Chromium-family: no export needed

Chrome, Edge, Brave, Vivaldi, Chromium, Opera and Opera GX all keep bookmarks in a JSON file that facetmark can find on its own. Reading it is safe while the browser is running.

shell
facetmark browsers        # what it can see
facetmark import          # import, if there is exactly one

If more than one profile is installed, the choice is not guessed — importing the wrong person's bookmarks is worse than one extra command. The candidates are printed and you pass the one you want:

shell
facetmark import "$HOME/.config/google-chrome/Default/Bookmarks"

Firefox and Safari: export to HTML first

BrowserWhere the export lives
FirefoxBookmarks → Manage Bookmarks → Import and Backup → Export Bookmarks to HTML
SafariFile → Export → Bookmarks
Chrome / Edge (manual route)chrome://bookmarks → ⋮ → Export bookmarks
Anything elseAny Netscape-format bookmarks.html works. It is a 1994 format and everyone still writes it.
shell
facetmark import ~/Downloads/bookmarks.html

What import reports

The same command handles Netscape HTML and Chrome JSON, and prints what it did rather than a spinner. On one real 1.7 MB export with 96 folders nested four deep, it parsed 1,710 entries, inserted 1,701, merged 9 duplicates and skipped 1 as non-indexable.

FieldMeaning
parsedEntries found in the file.
inserted / updatedNew rows, and existing rows whose title or folder changed.
merged_duplicatesSame URL saved twice; the earlier timestamp wins.
non_indexablejavascript:, place:, file: and friends.
missing_datesEntries with no save time. They still import, but they cannot join a saving session.
privacy_skippedSkipped by FACETMARK_PRIVACY_EXCLUDED_DOMAINS.
timestamp_unitWhich epoch the source used. Chrome and Netscape disagree; this says which one was detected.
Exclude domains before you import

Set FACETMARK_PRIVACY_EXCLUDED_DOMAINS to a comma-separated list and those hosts are never inserted, never fetched and never embedded. Easier than deleting rows afterwards.

03Model access

facetmark reaches models through one OpenAI-compatible endpoint. There is deliberately no provider-specific branching anywhere in the codebase: one base_url plus one api_key covers OpenAI, DeepSeek, Kimi, Zhipu, SiliconFlow, Aliyun Bailian, together.ai, Azure OpenAI, Ollama, vLLM, LM Studio and any internal gateway that speaks the same shape.

Two model roles are used. A chat model writes enrichment (summary, topics, entities, key points) and candidate intent queries. An embedding model turns page bodies into vectors.

Through an endpoint

shell
export FACETMARK_API_KEY=sk-...
export FACETMARK_BASE_URL=https://api.openai.com/v1
export FACETMARK_CHAT_MODEL=gpt-4o-mini
export FACETMARK_EMBED_MODEL=text-embedding-3-small
export FACETMARK_EMBED_DIM=1536
The base URL must end in /v1

This is the single most common setup failure. A base URL without /v1 produces a 404 on every call, including the first one, and the error comes from the provider rather than from facetmark so it reads as a credentials problem.

Instead of environment variables you can drop a .env file next to where you run the command. Same names, same prefix.

dotenv
FACETMARK_API_KEY=sk-...
FACETMARK_BASE_URL=https://api.deepseek.com/v1
FACETMARK_CHAT_MODEL=deepseek-chat

Shared or free endpoints

On endpoints where a listed model can be absent, out of quota, or unable to honour response_format, set a fallback chain. It is empty by default on purpose: a paid endpoint returning an error is telling you something, and swallowing it is worse than failing.

shell
export FACETMARK_CHAT_MODEL_FALLBACKS=deepseek-chat,qwen-plus

The provider records which model actually answered each call. Any report built on a failover chain has to publish that mix.

Local embeddings, no key

Runs the embedding model on your own machine through sentence-transformers. Combined with an empty API key, nothing except page fetching leaves the machine.

shell
pip install "facetmark[local]"

export FACETMARK_EMBED_BACKEND=local
export FACETMARK_EMBED_MODEL=bge-m3
export FACETMARK_EMBED_DIM=1024
export FACETMARK_LOCAL_EMBED_PATH=/path/to/bge-m3   # unset = download
export FACETMARK_LOCAL_EMBED_MAX_SEQ=1024
Why the sequence length default is 1024

Embedding the same document twice must land in the same place. On bge-m3 at 1024 tokens, the minimum self-cosine over a fixed 64-document probe set is 0.999976 with 64 of 64 documents matching themselves. At 512 tokens the minimum falls to 0.9769, because truncation starts cutting different amounts off the same text. That is why 1024 is the default and why lowering it is a real trade.

Changing the dimension invalidates everything

FACETMARK_EMBED_DIM is recorded in the meta table on the first index build. A later mismatch raises instead of silently mixing incompatible vectors. If you change embedding model or dimension, re-embed with facetmark index --force.

No model at all

Everything still installs and runs. You keep both lexical facets, saving sessions, the domain and link graph, and link health. You lose the content facet and the intent facet. facetmark search --quick is the explicit lexical-only path and makes no model call.

04Build the index

shell
facetmark index

One command runs every stage in order. Each stage is idempotent and fingerprinted, so running it again after adding fifty bookmarks does the work for fifty bookmarks, not for the whole library.

StageWhat it doesNeeds a model?
fetchDownloads each page, honouring robots.txt and per-domain rate limits. Extracts a readable body.no
enrichSummary, topics, entities, key points — one small chat call per page.chat
embed_contentEmbeds the reconstructed text of each page.embedding
intentsGenerates candidate queries for each page.chat
filter_intentsKeeps an intent only if searching it retrieves the page back. Typically a little under half survive.no
embed_intentsEmbeds the surviving intents.embedding
sessionsClusters saves into episodes by time gap, choosing the gap by coverage × purity lift against a shuffled control.no
edgesBuilds session, semantic, same-domain and supersession edges.no

Useful flags

FlagEffect
--no-fetchSkip crawling entirely and index titles only. Seconds instead of hours; much weaker results.
--limit NCap bookmarks per stage. Good for a first look at what a run will cost.
--forceIgnore fingerprints and redo work already done.
--mockDeterministic offline provider. No key, no network, no quality.
--jsonMachine-readable report of every stage, including per-stage seconds.

How fingerprints work

  • Enrichment is keyed on the hash of the page body. Same body, no second chat call.
  • Embedding is keyed on the reconstructed embed text, not on the body. So if enrichment changes and the embed text changes with it, the stale vector is detected rather than trusted — which is how the karakeep round-trip damage was caught.
  • Sessions and edges are rebuilt from scratch each run; they are cheap and depend on the whole library.

facetmark reindex throws away every derived artefact and rebuilds from the bookmarks themselves. facetmark migrate brings an older database up to the current schema, taking a snapshot first unless you pass --no-backup.

What indexing costs

Money is dominated by enrichment: roughly one small chat call per page, so a 1,700-page library on a cheap model is cents. Wall time is dominated by fetching, and fetching is deliberately slow — FETCH_PER_HOST_CONCURRENCY is 2 and there is a minimum interval between hits on one host.

For a sense of scale: that real 1,700-bookmark library, indexed with --no-fetch, produced 322 saving sessions, 9,132 edges, 1,386 distinct domains and 1,775 vectors.

06Serve: HTTP API and the pairing token

shell
facetmark serve        # 127.0.0.1:8787
Loopback is not an authorisation model

Every route that touches the library requires a token, even on localhost, because any process on your machine can reach 127.0.0.1. The open ones are /, /health, and the two the local page needs before it can send a header — /app, a static file with no data in it, and /app/boot, which answers only a loopback caller asking a loopback address. facetmark serve prints a warning when --host is anything other than a loopback address: the index contains your whole browsing interest graph.

The token

Minted on first run into pairing-token.txt in your data directory. Send it as the x-facetmark-token header.

shell
facetmark token             # print it
facetmark token --rotate    # invalidate the old one
shell
TOKEN=$(facetmark token)

curl -s http://127.0.0.1:8787/health

curl -s -X POST http://127.0.0.1:8787/search \
  -H 'content-type: application/json' \
  -H "x-facetmark-token: $TOKEN" \
  -d '{"q":"vectors inside sqlite","limit":5}'

POST /search

FieldTypeMeaning
qstringThe query. Required.
limitintResults to return.
configstringProfile or rung name. "" and "full" both resolve through default_config.
assistboolAllow the model-assisted understanding step.
expandboolReturn the one-hop graph group alongside the hits.

Every route

GroupRoutes
OpenGET / · GET /health
Local page — also openGET /app · GET /app/static/* · GET /app/boot
SearchGET /stats · GET /quick · POST /search · POST /suggest · POST /synthesize
RecordsGET /bookmark/{id} · GET /bookmark/{id}/related · POST /bookmark · POST /open
SessionsGET /sessions · GET /session/{id}
Indexing queueGET /queue/next · POST /queue/complete · GET /queue/stats
Link healthGET /link-health/summary · GET /link-health/{id} · POST /link-health/check · GET /graveyard
karakeep bridgePOST /karakeep/documents · POST /karakeep/documents/delete · POST /karakeep/search · POST /karakeep/clear · GET /karakeep/stats

07The local page

facetmark serve also hosts a search page. It is the one interface that needs nothing installed beyond facetmark itself — no browser extension to load, no editor to configure, no curl.

shell
facetmark serve
# facetmark 1.6.1  http://127.0.0.1:8787
# open the search page:     http://127.0.0.1:8787/app
# pairing token written to: ~/.facetmark/pairing-token.txt

Plain HTML, CSS and ES modules inside the Python package: no Node, no bundler, no build artefact that can go stale against the server it talks to. Because the page is served by the same process as the API it is same-origin, which is also why it cannot be hosted anywhere else — CORS on this service is restricted to browser-extension origins.

Two views

ViewAddressWhat it is for
Search/app#/searchThe query box and the ranked list. Typing paints a lexical result first, with no model call at all; the ranked answer replaces it when it arrives, and Load more pages through the rest.
Library/app#/libraryEverything facetmark stats prints, as labelled rows: bookmarks, how many have a body, how many are embedded, sessions, edges by kind, the fetch queue, link health, and the cold-layer census. This is the view that answers “I searched and got nothing”.
What it deliberately does not do

It reads. There is no delete, no edit, no queue control and no synthesize button. Those exist on the command line and in the API, where a mistake is at least deliberate. The one thing the page writes is a POST /open when you follow a result, which is what feeds the cold layer.

What the markers on a row mean

The same vocabulary the extension popup uses. In the page each one carries a one-line explanation on hover; the table is here so you can read them all at once.

MarkerMeansDefault
aboutThe content facet matched — a vector over the page’s own text.on
asked asThe intent facet matched — vectors over questions generated for the page.off
wordsThe lexical · segments facet matched — FTS5 over whole words in the title, folder or address.off
substringThe lexical · trigram facet matched — FTS5 over characters, which is what makes partial words and Chinese queries hit.off
coldSaved long ago, never opened, and something newer looks like it replaced it. Ranked lower, never deleted.on
saved around theseThe second group: one hop over the link graph from a result above. Never mixed into the ranking.on

Rows in that second group carry a chip for the edge that reached them — same sitting (saved in the same browsing session), similar (close in meaning), replaced by, same page, same site. The weights behind those names are in the settings table.

How the page gets the token

It asks GET /app/boot, which is the only route that can hand out the pairing token, and only when both the caller and the address in the request are loopback. On your own machine both are true and the page pairs itself with nothing to copy.

Why the second condition exists

A page on the open web can point a hostname at 127.0.0.1 and have your browser make the request — the caller really is loopback. What it cannot do is change the Host header, which still carries the attacker’s domain. Checking it is what keeps a website from reading your token, and it is why this is a separate route rather than a flag on an existing one.

Behind a reverse proxy, or on a LAN address, that check fails on purpose: the page then shows a field and you paste facetmark token once. It is kept in that browser’s local storage, not in the page.

Keyboard

KeyDoes
/Focus the query box from anywhere on the page.
EnterSearch.
Walk the results. From the box, enters the list.
EscClear the query and go back to the box.

Language and theme

English and Chinese, switched in the header and remembered. Without a stored choice the page follows the browser’s language. The theme switch cycles system → light → dark and shares its stored key with this site, so a reader who picked dark here gets dark there. Everything animated is inside a prefers-reduced-motion query.

08Paging: limit, offset and depth

Every search surface takes limit, offset and depth, and every search response reports the window it actually served rather than echoing what you asked for.

shell
facetmark search "kafka rebalance" -n 20
facetmark search "kafka rebalance" -n 20 -o 20 --depth 60

The CLI prints the --offset and --depth for the next page whenever there is one. Over HTTP the same three fields go in the POST /search body:

json
{
  "hits": [ ],
  "limit": 20,          // served, after clamping
  "offset": 20,
  "depth": 60,          // the depth this ranking ran at
  "total": 137,         // ranked so far; a floor when capped
  "has_more": true,
  "depth_capped": false
}
FieldMeaning
limitRows in this page. Clamped to MAX_PAGE_SIZE, 200 by default.
offsetRows skipped. Clamped below MAX_CANDIDATE_DEPTH.
depthHow deep each facet was read before fusion. Omit it and it is derived from the window; send back the value the previous page reported and this page continues that same ranking.
totalDocuments the fusion step ranked. A lower bound, not a library count, and explicitly a floor when depth_capped is true.
has_moreThere is something past this window. Exact under the shipped single-facet default; an upper bound with several facets in play, where the overflow row can turn out to be a document the pool already held.
depth_cappedMore exists and the reason we stopped is the depth ceiling rather than your window — the difference between “press next” and “raise the depth or narrow the query”.

Why depth is a parameter and not an implementation detail

Page size and retrieval depth used to be the same number: asking for more rows quietly retrieved deeper, and result 51 was unreachable at any page size because the pool was 50 rows regardless. Now the page is a window onto a pool whose size you can see and pin.

Pin the depth or page two can disagree with page one

RRF is only rank-stable under a growing pool when there is one facet. A document’s score is a sum over the facets that ranked it within the depth asked for, so a deeper pool can hand a document a term it did not have — and that term can outweigh a rival’s whole score. Rank 2 in one facet plus rank 40 in another beats a sole rank 1 (1/62 + 1/100 against 1/61) but contributes nothing at depth 30.

So with several facets on, growing the depth to reach page 2 lets page 2 disagree with page 1 about what page 1 was. The fix is not to grow it: send back the depth the previous page reported and every page is a slice of one ranking. The local page and the browser extension both do this.

The two ceilings

MAX_PAGE_SIZE (200) bounds one page. MAX_CANDIDATE_DEPTH (2000) bounds the pool behind all of them, and hitting it is what sets depth_capped. Both are clamped in one place, before any query runs, so an oversized request costs nothing and is answered with the window that was actually served.

09Browser extension

Manifest V3, for Chromium-family browsers. It talks to 127.0.0.1:8787 and nothing else — those are its only required host permissions.

  1. Download facetmark-extension.zip from the releases page and unzip it.
  2. Open chrome://extensions, turn on Developer mode, choose Load unpacked and select the unzipped folder.
  3. Run facetmark serve in a terminal and leave it running.
  4. Run facetmark token, open the extension's options page, and paste the token.
  5. Press Ctrl+Shift+K (Cmd+Shift+K on macOS) and search.

What it gives you

FeatureDetail
Omnibox keywordType fm then a space in the address bar and search without opening the popup.
Keyboard shortcutCtrl+Shift+K / Cmd+Shift+K.
Save the current tabOne click. The page joins a local indexing queue and the popup footer shows how many are waiting.
Context menuRight-click a link or a page to save it.
Grouped resultsPages from the same saving session arrive as their own group instead of being mixed into the ranking.
Facet labelsEach hit shows which facets matched — about, asked as, words, substring, linked, cold.

Options

FieldMeaning
endpointWhere facetmark is listening. Default http://127.0.0.1:8787.
tokenOutput of facetmark token.
channelBAn optional second endpoint, for running two libraries.
pausedStop the extension talking to the service without uninstalling it.
Not in the web stores

The extension is distributed as a zip on the releases page and installed unpacked. It has not been submitted to the Chrome Web Store or the Edge add-ons catalogue.

10MCP server

facetmark mcp runs a FastMCP server on stdio, so an MCP client such as Claude Desktop can search your library, read a saving session, and save a page.

json
{
  "mcpServers": {
    "facetmark": {
      "command": "facetmark",
      "args": ["mcp"]
    }
  }
}

Add "--db", "/path/to/facetmark.db" to args to point at a specific library, or "--mock" to try it with no key. Environment variables are read the same way as for every other command.

Nine tools

ToolDoes
search_bookmarksThe full pipeline, same as facetmark search.
get_bookmarkOne record, optionally with the body.
list_sessionsRecent saving episodes.
get_sessionEverything saved in one episode.
find_relatedOne hop out in the link graph.
synthesizeA model-written answer grounded in retrieved pages.
suggest_from_contextWhat in the library relates to text you are looking at.
check_link_healthWhether a saved URL is still alive.
save_bookmarkAdd a URL and queue it for indexing.

Three resources

  • bookmark://{id} — one record as JSON.
  • session://{id} — one saving episode.
  • facetmark://stats — index size and coverage.

11karakeep plugin

karakeep is a self-hosted bookmark manager with a pluggable search provider. This plugin puts facetmark behind its search box, so karakeep keeps the UI and facetmark does the retrieval.

  1. Copy the plugin into karakeep's plugin package.
  2. Register it in the exports map.
  3. Load it after meilisearch, because the plugin manager hands out the last provider registered.
  4. Point it at a running facetmark service.
shell
cp -r integrations/karakeep/search-facetmark \
  /path/to/karakeep/packages/plugins/search-facetmark
json
// packages/plugins/package.json — exports map
"./search-facetmark": "./search-facetmark/index.ts"
ts
// packages/shared-server/src/plugins.ts, in loadAllPlugins()
await import("@karakeep/plugins/search-meilisearch");
await import("@karakeep/plugins/search-facetmark");  // must come after
shell
export FACETMARK_URL=http://127.0.0.1:8787
export FACETMARK_TOKEN=$(facetmark token)
facetmark serve

How the contract is kept honest

  • Upstream karakeep types are pinned by blob SHA in integrations/karakeep/typecheck/upstream-pins.json, and CI runs tsc --noEmit against them.
  • The wire format is captured in integrations/karakeep/contract/wire.json and replayed by tests/test_karakeep_contract.py.
  • That replay test caught a real one: at offset 1 of a single match, the correct answer is hits: [] with totalHits: 1. An empty hits array is not the same as no results.
Two things to know before you rely on it

First, there is no test against a live karakeep instance — only against the pinned contract. Second, pushing your library through karakeep and back changes the ranking: karakeep's tags are your browser's folder labels, so the keyword line collapses from 19,016 distinct terms to 13. Metric-level conclusions survive the round trip; rank-level ones do not until you re-index. The full measurement.

To uninstall the bridge, drop the karakeep_doc table. enrichment.source_hash == 'karakeep' is reserved and means the bridge may overwrite that row; any other value means a real model wrote it and the bridge leaves it alone.

12What is in the database

One SQLite file. Open it with any SQLite browser; nothing is encrypted, obfuscated or proprietary. If you stop using facetmark, your data is still readable.

TableHolds
bookmarkURL, title, folder path, save timestamp, source.
contentThe fetched body and its extracted text.
enrichmentSummary, topics, entities, key points, and the source_hash fingerprint.
intentGenerated candidate queries and whether each one survived the retrieve-it-back filter.
vec_content / vec_intentsqlite-vec virtual tables holding the dense vectors.
fts_tri / fts_segTwo FTS5 indexes: character trigrams and word segments.
session / bookmark_sessionReconstructed saving episodes and their membership.
edgeTyped links: session, semantic, same_domain, supersession.
healthLink-health verdicts: ok, gone, drifted, soft_gone.
karakeep_docBridge state. Drop it to uninstall the bridge.
metaEmbedding model, dimension and backend, recorded at first build and enforced afterwards.

Link health and the cold layer

shell
facetmark health                       # what is known
facetmark health --check               # actually probe the network
facetmark health --check --no-save-recovered   # read-only sweep

The sweep can use DNS-over-HTTPS, the Wayback availability API and a reader proxy to distinguish “gone” from “your DNS is broken”. Use --no-save-recovered before measuring anything against a library, so the sweep stays read-only apart from the health log.

A known, load-bearing bug

The cold layer treats “the URL died” as “the saved copy is useless”, which is wrong: facetmark stores the body, so a dead URL is when the local snapshot matters most. It is not fixed yet, because in the shipped profile a second accident stops the demotion from ever executing, and removing either one alone makes results worse by a measured 1.46pp. The whole story.

13Every setting

Prefix every name with FACETMARK_ as an environment variable, or put it unprefixed in a .env file. Defaults below are the shipped values.

Storage

SettingDefaultNotes
DATA_DIRper-OSSee install.
DB_NAMEfacetmark.db
PRIVACY_EXCLUDED_DOMAINSemptyNever imported, fetched or embedded.

Model access

SettingDefaultNotes
API_KEYemptyEmpty is legal; you lose the content and intent facets.
BASE_URLhttps://api.openai.com/v1Must end in /v1.
CHAT_MODELgpt-4o-mini
CHAT_MODEL_FALLBACKSemptyComma-separated. Empty on purpose.
EMBED_MODELtext-embedding-3-small
EMBED_DIM1536Recorded in meta; a mismatch raises.
EMBED_BACKENDendpointOr local.
REQUEST_TIMEOUT60.0Seconds.
MAX_RETRIES3
USE_MOCK_PROVIDERfalseDeterministic offline provider.

Local embeddings

SettingDefaultNotes
LOCAL_EMBED_PATHemptyEmpty downloads the model.
LOCAL_EMBED_DEVICEcpu
LOCAL_EMBED_BATCH8
LOCAL_EMBED_MAX_SEQ1024Lowering it costs reproducibility — see model access.

Fetching

SettingDefaultNotes
FETCH_CONCURRENCY30Global.
FETCH_PER_HOST_CONCURRENCY2Politeness, not performance.
FETCH_PER_HOST_MIN_INTERVAL0.5Seconds between hits on one host.
FETCH_TIMEOUT15.0
RESPECT_ROBOTStrue
ROBOTS_ON_ERRORallowWhat to do when robots.txt cannot be read.
ROBOTS_MAX_CRAWL_DELAY5.0Cap on an advertised crawl delay.
MIN_BODY_CHARS200Below this the page counts as body-less.
BODY_TRUNCATE_CHARS6000
USER_AGENTidentifies facetmark

Enrichment and intents

SettingDefaultNotes
ENRICH_CONCURRENCY4
INTENT_GENERATE_N8Candidates generated per page.
INTENT_KEEP_N4Kept per page, at most.
INTENT_PROBE_TOP_K10How deep the retrieve-it-back filter looks.

Sessions, retrieval and decay

SettingDefaultNotes
SESSION_EPS_MINUTESautoUnset means the gap is chosen by coverage × purity lift over a grid.
SESSION_EPS_GRID_MINUTES5…240The grid it searches.
RRF_K60The k in w / (k + rank).
CANDIDATES_PER_FACET50
GRAPH_EXPAND_HOPS1
GRAPH_EXPAND_FACTOR0.6
DECAY_FACTOR0.5
DECAY_AGE_DAYS365
DECAY_RESCUE_THRESHOLD0.02See the decay measurement before changing this.

Link health and service

SettingDefaultNotes
HEALTH_ENABLE_EXTERNALtrueMaster switch for network probes.
HEALTH_ENABLE_DOHtrueDNS-over-HTTPS.
HEALTH_ENABLE_WAYBACKtrue
HEALTH_ENABLE_READERtrue
HEALTH_SOFT_GONE_LENGTH_RATIO0.30Body shrank this much ⇒ soft_gone.
HEALTH_GONE_CONFIRM_DAYS7
HEALTH_PROXY_URLunset
HOST127.0.0.1
PORT8787

14Every command

Every command takes --db to point at a specific database file or data directory. Most take --json.

CommandDoesNotable flags
versionPrint the version.
browsersList live browser profiles that can be imported.--json
import [PATH]Import a Netscape HTML export or a Chrome JSON profile. With no path, finds the live profile. Never writes back.
migrateBring the schema up to what this build expects.--check, --no-backup
indexFetch, enrich, embed, intents, sessions, edges.--no-fetch, --limit, --force, --mock
reindexRebuild every derived artefact from the bookmarks.--mock
search QUERYSearch the library.-n, --quick, --config, --explain
show IDPrint one bookmark as JSON.--body
sessionsList saving episodes.-n
healthLink health, and whether the decay layer can see any of it.--check, --no-external, --no-save-recovered
statsIndex size and coverage.
tokenPrint the extension's pairing token.--rotate
serveRun the local HTTP service.--host, --port, --mock
mcpRun the MCP server on stdio.--mock
demoBuild a synthetic library offline and search it.--size, --keep
evalRun the retrieval evaluation, optionally as an A–E ablation.--ablation, --rungs, --queries, --bootstrap, --out

Running your own evaluation

This is the part of facetmark that matters most and the part nobody else has used yet. Give it a JSONL file of {text, qtype, target_url} and it will run any set of rungs against your own library with bootstrap confidence intervals and a McNemar test on the paired differences.

shell
facetmark eval --no-build \
  --queries my-queries.jsonl \
  --rungs A,C,full \
  --bootstrap 10000 --concurrency 4 \
  --out report.json
Concurrency destroys the latency numbers

--concurrency > 1 makes p50 and p95 meaningless. Use it for the quality numbers, then re-run at concurrency 1 on a subsample if you need latency.

15Troubleshooting

Every model call returns 404

The base URL is missing /v1. This is the most common setup failure by a wide margin, and the error surfaces as a provider error, so it reads like a credentials problem.

“dimension mismatch” on index or search

The embedding dimension recorded in meta at first build no longer matches FACETMARK_EMBED_DIM. facetmark refuses to mix vector dimensions rather than silently return nonsense. Either restore the old dimension, or re-embed everything with facetmark index --force.

Enrichment silently does nothing

The stored source_hash already equals the current body hash, so the fingerprint says the work is done. That is correct behaviour, and facetmark index --force overrides it.

Vectors exist but results are bad

Usually the embed text changed after the vector was written — for example because enrichment was replaced by a bridge. Re-embed with facetmark index --force. If results are bad on a fresh index instead, check whether you are accidentally on the mock provider: facetmark stats reports the embedding model in use.

disk I/O error from SQLite

SQLite cannot run reliably on some network and FUSE filesystems. Move the data directory to local disk with FACETMARK_DATA_DIR.

Fetching is slow, or pages come back empty

Both are usually intentional. robots.txt is honoured and per-host concurrency is capped at 2 with a minimum interval between hits. Some sites simply refuse. A page with no body still indexes — the pipeline falls back to a title-only fingerprint — it is just weaker. Use --no-fetch if you want a fast, shallow index.

The extension cannot reach the service

Check three things in order: facetmark serve is actually running; the endpoint in options matches the host and port it bound; the token in options matches facetmark token. If you rotated the token, the extension needs the new one.

Something else

facetmark stats and facetmark health print what the index actually contains, which resolves most confusion. Beyond that, open an issue — the --json output of the failing command is the most useful thing to paste.