Command and API reference

Find import formats, query syntax, pagination, HTTP APIs, integrations and settings by task. Complete the quickstart first, then return here for individual parameters.

Install#

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,700+ 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/

The data directory holds the database, pairing token and optional configuration. Back up the database and secrets separately; confirm paths with facetmark stats and facetmark config path.

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

See also: Quickstart

Get 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.

See also: Quickstart

Model access#

Online models share one OpenAI-compatible base_url and api_key. Verify that the endpoint supports the chat and embedding capabilities you need. A compatible gateway, self-hosted service or local embedding backend can cover missing capabilities.

The chat model generates summaries, topics, entities, key points and candidate questions. The embedding model converts page text and queries into vectors. Some derived content may be inferred from titles when bodies are missing.

Through an endpoint

shell
export FACETMARK_API_KEY=sk-...
export FACETMARK_BASE_URL=https://api.openai.com/v1
export FACETMARK_CHAT_MODEL=gpt-6-luna
export FACETMARK_EMBED_MODEL=text-embedding-3-small
export FACETMARK_EMBED_DIM=1536
Use the provider API base path

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-flash

Shared or free endpoints

Fallback chat models are tried in the configured order. Check that each is available on your account and endpoint; a fallback chain should not conceal incorrect endpoints or permissions.

shell
export FACETMARK_CHAT_MODEL_FALLBACKS=deepseek-flash,deepseek-v4-pro

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

Local embeddings use sentence-transformers. After downloading the model, computation runs on the service machine; page fetching still contacts websites. If online chat is configured, summaries and synthesis may still call that provider.

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=BAAI/bge-m3   # or a local model directory
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.

See also: Models and configuration

Build the index#

shell
facetmark index

Indexing runs in stages and reuses completed work by input fingerprint. New bookmarks or changes to text and model settings can require stages to run again; check the actual job output.

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

Cost depends on text length, models and provider pricing; start with a small sample. Fetching and site rate limits also affect duration. The default per-host concurrency is 2, with an interval between requests.

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.

See also: Quickstart

The query language#

Every search surface takes the same grammar: the web box, facetmark search, /search and /quick, the MCP tools, the karakeep plugin. It is a filter language, not a second ranker — a filter decides which pages are eligible and never moves a surviving page's score. Full reference: docs/query-language.md.

shell
facetmark search "postgres domain:github.com -title:tutorial"
facetmark search "kafka added:<7d"          # saved this week
facetmark search 'title:encryption (signal|matrix)'
facetmark search "tag:work sort:date"       # a browse, newest first
FieldMatchesExample
domain: alias site:the site, exact or wildcarddomain:github.com
host:the full hostnamehost:news.ycombinator.com
url:part of the addressurl:*/docs/*
title:words in the title onlytitle:encryption
text:words in the fetched page bodytext:"GDPR compliance"
folder:the browser folder it came fromfolder:study
tag:one of your own tags, exactlytag:work
topic:a topic the enrichment wrotetopic:postgres
lang:the detected page languagelang:zh
opened:how many times you opened itopened:10..

Negation, phrases, alternation, wildcards

shell
-facebook                    exclude a word
-domain:pinterest.com        exclude a whole site
"consumer group rebalancing"  an exact phrase
(security|privacy)           either word
domain:(github.com|gitlab.com)   either value
domain:*.github.io           * is any run of characters

Dates

A duration compares against the age of the bookmark, so added:>90d means older than 90 days. An absolute date compares against the timestamp directly, so added:>=2026-04-01 means on or after that day. The two read in opposite directions because both readings are the obvious one for their own form.

WrittenMeans
added:<7dsaved in the last week
added:>90dolder than 90 days
added:2026-04saved that month
added:2026-04-01..2026-09-01an explicit range
before:2026-05-01 after:30daliases of added:; a duration on these two is an age, so after:30d is the last 30 days

Sorting, and what a browse is

sort:date puts newest saves first; sort:-date puts oldest first. title, domain, url and opened are also supported. With filters or sorting but no free text, the query browses bookmarks without model calls. Lexical and local embedding search also avoid online embedding charges.

A query with no syntax is unchanged

The parser only reads a token as syntax when it could not be plain text. note: something is not a filter, because note is not a field. state-of-the-art is not three negations. https://example.com/x is one word. And a value that does not parse — added:90d, which is a duration with no comparison — comes back in the response's filters.ignored rather than being silently applied or silently dropped.

Your own tags

Netscape and pinboard exports carry a TAGS attribute, and it is kept: stored on the bookmark, returned with every hit, and queryable as tag:work. The match is on one whole element of the list, so tag:work never widens into workshop; use tag:(work|rust) for more than one. POST /bookmark and the MCP save_bookmark tool accept tags too, and re-importing a file unions the tags rather than replacing them.

Serve: 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

The token is generated on first run and stored in pairing-token.txt in the data directory. Send Authorization: Bearer <token> or x-facetmark-token. Keep it out of public links and logs.

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
Administration

Admin routes additionally require a loopback peer and can be disabled with FACETMARK_ADMIN_API=false. Follow the SSH forwarding steps for remote administration.

The local page#

facetmark serve provides the /app interface and HTTP API. The app includes search, synthesis, Library, Sessions and System; the gear opens Settings.

shell
facetmark serve
# facetmark 2.0.0  http://127.0.0.1:8787
# open the search page:     http://127.0.0.1:8787/app
# pairing token written to the data directory
#   (facetmark config show lists the effective data_dir)

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.

Five tabs and administration entry points

ViewAddressWhat it is for
Search/app#/searchCommitted input first shows lexical results without a model call, then the full retrieval results. Queries pause during IME composition. Use Load more to continue browsing.
Ask/app#/askSubmit a question to generate an answer from stored summaries or snippets, with numbered citations. Follow the sources and verify the original pages.
Library/app#/libraryInspect save activity, text and vector coverage, sessions and connections. When search finds nothing, first check that bookmarks were imported and their text and indexes are available.
Sessions/app#/sessionsFind bookmarks saved together and open a session to continue reading.
System/app#/systemInspect service and model status, fetch queues, link health and the cold-layer list.
Settings (gear)/app#/settingsTest and save model configuration, adjust indexing options, and start or cancel an index job.
First run/app#/setupImport bookmarks, choose models and build the index. You can revisit these steps with an existing library.
Which actions require administration access

Paired connections can search, generate answers and inspect bookmarks. Importing files, saving configuration and controlling index jobs additionally require loopback access or SSH forwarding. Opening an original page from a result records reading activity; search and detail views do not offer bookmark editing or deletion.

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 — an embedding of stored content, which can include a title-derived summary.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
/Open and focus Search when you are not typing in another field.
EnterSubmit the query, or accept a selected suggestion. Confirming IME text does not submit.
↑ ↓Select suggestions when the list is open; otherwise move through search results. During composition these keys stay with the IME.
EscClose suggestions or details first; with neither open on Search, clear and focus the query.

Language and theme

Language and theme preferences are stored in the current browser and origin. The website and app use the same preference keys, but browser storage is not shared across domains. The interface respects reduced-motion preferences.

See also: Using the app

Paging: 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 controls how many results you see; retrieval depth controls the candidate pool. Reuse the response depth for subsequent pages to keep the ranking basis consistent.

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.

Browser 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.

See also: Integrations and backups

MCP 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.

See also: Integrations and backups

karakeep 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.

See also: Integrations and backups

What 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.

Every 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-6-luna
CHAT_EXTRA_BODYJSON object string for reasoning and output limits. Empty uses endpoint defaults without forced sampling.
EMBED_SEND_DIMENSIONSfalsedimensions
EMBED_BATCH_SIZE64Use 20 for Bailian.
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

See also: Models and configuration

Every command#

Use facetmark COMMAND --help to check each command. Database commands generally support --db; many offer --json for scripts.

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.
export [FILE] [QUERY]Write the library, or the part a filter query names, as JSON that import reads back. Filters only — the top of a ranking is not a backup. Derived data is left out; index rebuilds it.--full
doctorDiagnose the install — configuration and where each setting came from, schema version, whether the indexes were built, whether the stored vectors match the settings. Repairs nothing and calls no model; every finding names the command that fixes it.--json
tokenPrint the extension's pairing token.--rotate
serveRun the local HTTP service.--host, --port, --mock
mcpRun the MCP server on stdio.--mock
crawl URLWalk a site into the library, politely. robots.txt is honoured, hosts on the privacy exclusion list are not contacted at all, and each page becomes an ordinary bookmark.--max-pages, --off-domain
updateSay whether a newer facetmark is on PyPI. It checks only when you run it — no background check, no telemetry — and never upgrades anything itself.--json
demoBuild a synthetic library offline and search it.--size, --keep
config path / config showWhere config.toml lives, and the effective settings with the source of each one.
evalRun the retrieval evaluation, optionally as an A–E ablation.--ablation, --rungs, --queries, --bootstrap, --out

Running your own evaluation

Evaluation accepts JSONL queries with {text, qtype, target_url}, compares retrieval configurations on a specified library, and reports confidence intervals and paired tests. Fix the dataset and protocol before interpreting results.

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.

See also: Integrations and backups

Troubleshooting#

Every model call returns 404

Check the complete provider base URL, including its path prefix (often /v1), and the available model name. A 404 can indicate the URL or model name; it does not by itself prove the API key is invalid.

“dimension mismatch” on index or search

The database dimension differs from FACETMARK_EMBED_DIM. Confirm the model output dimension, correct the configuration, restart the service and rebuild the index. Keep a database backup before changing it.

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.

See also: Quickstart