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.
pip install facetmark
# or, if you use uv:
uv pip install facetmark
facetmark versionWith 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.
pip install "facetmark[local]"From source
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 scriptsIt 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.
| Platform | Default 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.
facetmark demo --size 6002Get 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.
facetmark browsers # what it can see
facetmark import # import, if there is exactly oneIf 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:
facetmark import "$HOME/.config/google-chrome/Default/Bookmarks"Firefox and Safari: export to HTML first
| Browser | Where the export lives |
|---|---|
| Firefox | Bookmarks → Manage Bookmarks → Import and Backup → Export Bookmarks to HTML |
| Safari | File → Export → Bookmarks |
| Chrome / Edge (manual route) | chrome://bookmarks → ⋮ → Export bookmarks |
| Anything else | Any Netscape-format bookmarks.html works. It is a 1994 format and everyone still writes it. |
facetmark import ~/Downloads/bookmarks.htmlWhat 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.
| Field | Meaning |
|---|---|
parsed | Entries found in the file. |
inserted / updated | New rows, and existing rows whose title or folder changed. |
merged_duplicates | Same URL saved twice; the earlier timestamp wins. |
non_indexable | javascript:, place:, file: and friends. |
missing_dates | Entries with no save time. They still import, but they cannot join a saving session. |
privacy_skipped | Skipped by FACETMARK_PRIVACY_EXCLUDED_DOMAINS. |
timestamp_unit | Which epoch the source used. Chrome and Netscape disagree; this says which one was detected. |
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
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=1536This 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.
FACETMARK_API_KEY=sk-...
FACETMARK_BASE_URL=https://api.deepseek.com/v1
FACETMARK_CHAT_MODEL=deepseek-chatShared 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.
export FACETMARK_CHAT_MODEL_FALLBACKS=deepseek-chat,qwen-plusThe 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.
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=1024Embedding 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.
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
facetmark indexOne 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.
| Stage | What it does | Needs a model? |
|---|---|---|
fetch | Downloads each page, honouring robots.txt and per-domain rate limits. Extracts a readable body. | no |
enrich | Summary, topics, entities, key points — one small chat call per page. | chat |
embed_content | Embeds the reconstructed text of each page. | embedding |
intents | Generates candidate queries for each page. | chat |
filter_intents | Keeps an intent only if searching it retrieves the page back. Typically a little under half survive. | no |
embed_intents | Embeds the surviving intents. | embedding |
sessions | Clusters saves into episodes by time gap, choosing the gap by coverage × purity lift against a shuffled control. | no |
edges | Builds session, semantic, same-domain and supersession edges. | no |
Useful flags
| Flag | Effect |
|---|---|
--no-fetch | Skip crawling entirely and index titles only. Seconds instead of hours; much weaker results. |
--limit N | Cap bookmarks per stage. Good for a first look at what a run will cost. |
--force | Ignore fingerprints and redo work already done. |
--mock | Deterministic offline provider. No key, no network, no quality. |
--json | Machine-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.
05Search
facetmark search "the post about keeping vectors in sqlite"
facetmark search "sqlite-vec" -n 20 --explain
facetmark search "error EADDRINUSE" --quick| Flag | Effect |
|---|---|
-n, --limit | Results to return. Default 10. |
--quick | Lexical only. No model call, no network, sub-millisecond. |
--explain | Print which facet matched each hit. The fastest way to understand why something ranked where it did. |
--config NAME | Run a specific profile or ablation rung. Default full. |
--json | Machine-readable, including timings per stage. |
Profiles and rungs
--config accepts any pre-registered rung, any shipped profile, and about twenty exploratory ablations. facetmark eval --help documents the rung syntax; the rungs themselves are listed in search/pipeline.py.
| Name | Facets and stages | Status |
|---|---|---|
A | content vector only | W1 winner · 0.643 |
B | content + both lexical facets | −5.4pp |
C | all four facets | measured |
D | all four + context + graph | measured |
E | all four + context + graph + rerank | measured |
full | content + graph + decay | default, real provider |
fused | all four + context + graph + rerank + decay | default, mock provider |
The mock hashes text into a vector, so the content facet — the one that wins outright on a real library — is exactly the one that returns noise on a mock one. Dropping the lexical facets there would leave the deployment with nothing that works. Real embeddings get the measurement's answer; everyone else gets the pre-gate behaviour, which at least retrieves by words.
What the ranking is made of
Selected facets each return up to CANDIDATES_PER_FACET hits. Reciprocal rank fusion combines them as sum_f w_f / (k + rank_f) with k = 60. Then context, decay and rerank run in that order, and one-hop graph expansion is returned as a separate group — not mixed into the ranking, because it was measured as an addition, not a replacement.
By design. The reranker reorders the top 20 but deliberately preserves the fused score on each hit, so a reordered list shows scores out of order. If it overwrote them you could no longer see what fusion thought.
Reading a saving session
facetmark sessions -n 20 # recent saving episodes
facetmark show 412 --body # one bookmark as JSON
facetmark stats # index size and coverage06Serve: HTTP API and the pairing token
facetmark serve # 127.0.0.1:8787Every 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.
facetmark token # print it
facetmark token --rotate # invalidate the old oneTOKEN=$(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
| Field | Type | Meaning |
|---|---|---|
q | string | The query. Required. |
limit | int | Results to return. |
config | string | Profile or rung name. "" and "full" both resolve through default_config. |
assist | bool | Allow the model-assisted understanding step. |
expand | bool | Return the one-hop graph group alongside the hits. |
Every route
| Group | Routes |
|---|---|
| Open | GET / · GET /health |
| Local page — also open | GET /app · GET /app/static/* · GET /app/boot |
| Search | GET /stats · GET /quick · POST /search · POST /suggest · POST /synthesize |
| Records | GET /bookmark/{id} · GET /bookmark/{id}/related · POST /bookmark · POST /open |
| Sessions | GET /sessions · GET /session/{id} |
| Indexing queue | GET /queue/next · POST /queue/complete · GET /queue/stats |
| Link health | GET /link-health/summary · GET /link-health/{id} · POST /link-health/check · GET /graveyard |
| karakeep bridge | POST /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.
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.txtPlain 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
| View | Address | What it is for |
|---|---|---|
| Search | /app#/search | The 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#/library | Everything 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”. |
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.
| Marker | Means | Default |
|---|---|---|
| about | The content facet matched — a vector over the page’s own text. | on |
| asked as | The intent facet matched — vectors over questions generated for the page. | off |
| words | The lexical · segments facet matched — FTS5 over whole words in the title, folder or address. | off |
| substring | The lexical · trigram facet matched — FTS5 over characters, which is what makes partial words and Chinese queries hit. | off |
| cold | Saved long ago, never opened, and something newer looks like it replaced it. Ranked lower, never deleted. | on |
| saved around these | The 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.
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
| Key | Does |
|---|---|
| / | Focus the query box from anywhere on the page. |
| Enter | Search. |
| ↑ ↓ | Walk the results. From the box, ↓ enters the list. |
| Esc | Clear 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.
facetmark search "kafka rebalance" -n 20
facetmark search "kafka rebalance" -n 20 -o 20 --depth 60The 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:
{
"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
}| Field | Meaning |
|---|---|
limit | Rows in this page. Clamped to MAX_PAGE_SIZE, 200 by default. |
offset | Rows skipped. Clamped below MAX_CANDIDATE_DEPTH. |
depth | How 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. |
total | Documents the fusion step ranked. A lower bound, not a library count, and explicitly a floor when depth_capped is true. |
has_more | There 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_capped | More 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.
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.
- Download
facetmark-extension.zipfrom the releases page and unzip it. - Open
chrome://extensions, turn on Developer mode, choose Load unpacked and select the unzipped folder. - Run
facetmark servein a terminal and leave it running. - Run
facetmark token, open the extension's options page, and paste the token. - Press Ctrl+Shift+K (Cmd+Shift+K on macOS) and search.
What it gives you
| Feature | Detail |
|---|---|
| Omnibox keyword | Type fm then a space in the address bar and search without opening the popup. |
| Keyboard shortcut | Ctrl+Shift+K / Cmd+Shift+K. |
| Save the current tab | One click. The page joins a local indexing queue and the popup footer shows how many are waiting. |
| Context menu | Right-click a link or a page to save it. |
| Grouped results | Pages from the same saving session arrive as their own group instead of being mixed into the ranking. |
| Facet labels | Each hit shows which facets matched — about, asked as, words, substring, linked, cold. |
Options
| Field | Meaning |
|---|---|
endpoint | Where facetmark is listening. Default http://127.0.0.1:8787. |
token | Output of facetmark token. |
channelB | An optional second endpoint, for running two libraries. |
paused | Stop the extension talking to the service without uninstalling it. |
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.
{
"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
| Tool | Does |
|---|---|
search_bookmarks | The full pipeline, same as facetmark search. |
get_bookmark | One record, optionally with the body. |
list_sessions | Recent saving episodes. |
get_session | Everything saved in one episode. |
find_related | One hop out in the link graph. |
synthesize | A model-written answer grounded in retrieved pages. |
suggest_from_context | What in the library relates to text you are looking at. |
check_link_health | Whether a saved URL is still alive. |
save_bookmark | Add 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.
- Copy the plugin into karakeep's plugin package.
- Register it in the exports map.
- Load it after meilisearch, because the plugin manager hands out the last provider registered.
- Point it at a running facetmark service.
cp -r integrations/karakeep/search-facetmark \
/path/to/karakeep/packages/plugins/search-facetmark// packages/plugins/package.json — exports map
"./search-facetmark": "./search-facetmark/index.ts"// packages/shared-server/src/plugins.ts, in loadAllPlugins()
await import("@karakeep/plugins/search-meilisearch");
await import("@karakeep/plugins/search-facetmark"); // must come afterexport FACETMARK_URL=http://127.0.0.1:8787
export FACETMARK_TOKEN=$(facetmark token)
facetmark serveHow the contract is kept honest
- Upstream karakeep types are pinned by blob SHA in
integrations/karakeep/typecheck/upstream-pins.json, and CI runstsc --noEmitagainst them. - The wire format is captured in
integrations/karakeep/contract/wire.jsonand replayed bytests/test_karakeep_contract.py. - That replay test caught a real one: at offset 1 of a single match, the correct answer is
hits: []withtotalHits: 1. An emptyhitsarray is not the same as no results.
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.
| Table | Holds |
|---|---|
bookmark | URL, title, folder path, save timestamp, source. |
content | The fetched body and its extracted text. |
enrichment | Summary, topics, entities, key points, and the source_hash fingerprint. |
intent | Generated candidate queries and whether each one survived the retrieve-it-back filter. |
vec_content / vec_intent | sqlite-vec virtual tables holding the dense vectors. |
fts_tri / fts_seg | Two FTS5 indexes: character trigrams and word segments. |
session / bookmark_session | Reconstructed saving episodes and their membership. |
edge | Typed links: session, semantic, same_domain, supersession. |
health | Link-health verdicts: ok, gone, drifted, soft_gone. |
karakeep_doc | Bridge state. Drop it to uninstall the bridge. |
meta | Embedding model, dimension and backend, recorded at first build and enforced afterwards. |
Link health and the cold layer
facetmark health # what is known
facetmark health --check # actually probe the network
facetmark health --check --no-save-recovered # read-only sweepThe 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.
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
| Setting | Default | Notes |
|---|---|---|
DATA_DIR | per-OS | See install. |
DB_NAME | facetmark.db | |
PRIVACY_EXCLUDED_DOMAINS | empty | Never imported, fetched or embedded. |
Model access
| Setting | Default | Notes |
|---|---|---|
API_KEY | empty | Empty is legal; you lose the content and intent facets. |
BASE_URL | https://api.openai.com/v1 | Must end in /v1. |
CHAT_MODEL | gpt-4o-mini | |
CHAT_MODEL_FALLBACKS | empty | Comma-separated. Empty on purpose. |
EMBED_MODEL | text-embedding-3-small | |
EMBED_DIM | 1536 | Recorded in meta; a mismatch raises. |
EMBED_BACKEND | endpoint | Or local. |
REQUEST_TIMEOUT | 60.0 | Seconds. |
MAX_RETRIES | 3 | |
USE_MOCK_PROVIDER | false | Deterministic offline provider. |
Local embeddings
| Setting | Default | Notes |
|---|---|---|
LOCAL_EMBED_PATH | empty | Empty downloads the model. |
LOCAL_EMBED_DEVICE | cpu | |
LOCAL_EMBED_BATCH | 8 | |
LOCAL_EMBED_MAX_SEQ | 1024 | Lowering it costs reproducibility — see model access. |
Fetching
| Setting | Default | Notes |
|---|---|---|
FETCH_CONCURRENCY | 30 | Global. |
FETCH_PER_HOST_CONCURRENCY | 2 | Politeness, not performance. |
FETCH_PER_HOST_MIN_INTERVAL | 0.5 | Seconds between hits on one host. |
FETCH_TIMEOUT | 15.0 | |
RESPECT_ROBOTS | true | |
ROBOTS_ON_ERROR | allow | What to do when robots.txt cannot be read. |
ROBOTS_MAX_CRAWL_DELAY | 5.0 | Cap on an advertised crawl delay. |
MIN_BODY_CHARS | 200 | Below this the page counts as body-less. |
BODY_TRUNCATE_CHARS | 6000 | |
USER_AGENT | identifies facetmark |
Enrichment and intents
| Setting | Default | Notes |
|---|---|---|
ENRICH_CONCURRENCY | 4 | |
INTENT_GENERATE_N | 8 | Candidates generated per page. |
INTENT_KEEP_N | 4 | Kept per page, at most. |
INTENT_PROBE_TOP_K | 10 | How deep the retrieve-it-back filter looks. |
Sessions, retrieval and decay
| Setting | Default | Notes |
|---|---|---|
SESSION_EPS_MINUTES | auto | Unset means the gap is chosen by coverage × purity lift over a grid. |
SESSION_EPS_GRID_MINUTES | 5…240 | The grid it searches. |
RRF_K | 60 | The k in w / (k + rank). |
CANDIDATES_PER_FACET | 50 | |
GRAPH_EXPAND_HOPS | 1 | |
GRAPH_EXPAND_FACTOR | 0.6 | |
DECAY_FACTOR | 0.5 | |
DECAY_AGE_DAYS | 365 | |
DECAY_RESCUE_THRESHOLD | 0.02 | See the decay measurement before changing this. |
Link health and service
| Setting | Default | Notes |
|---|---|---|
HEALTH_ENABLE_EXTERNAL | true | Master switch for network probes. |
HEALTH_ENABLE_DOH | true | DNS-over-HTTPS. |
HEALTH_ENABLE_WAYBACK | true | |
HEALTH_ENABLE_READER | true | |
HEALTH_SOFT_GONE_LENGTH_RATIO | 0.30 | Body shrank this much ⇒ soft_gone. |
HEALTH_GONE_CONFIRM_DAYS | 7 | |
HEALTH_PROXY_URL | unset | |
HOST | 127.0.0.1 | |
PORT | 8787 |
14Every command
Every command takes --db to point at a specific database file or data directory. Most take --json.
| Command | Does | Notable flags |
|---|---|---|
version | Print the version. | |
browsers | List 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. | |
migrate | Bring the schema up to what this build expects. | --check, --no-backup |
index | Fetch, enrich, embed, intents, sessions, edges. | --no-fetch, --limit, --force, --mock |
reindex | Rebuild every derived artefact from the bookmarks. | --mock |
search QUERY | Search the library. | -n, --quick, --config, --explain |
show ID | Print one bookmark as JSON. | --body |
sessions | List saving episodes. | -n |
health | Link health, and whether the decay layer can see any of it. | --check, --no-external, --no-save-recovered |
stats | Index size and coverage. | |
token | Print the extension's pairing token. | --rotate |
serve | Run the local HTTP service. | --host, --port, --mock |
mcp | Run the MCP server on stdio. | --mock |
demo | Build a synthetic library offline and search it. | --size, --keep |
eval | Run 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.
facetmark eval --no-build \
--queries my-queries.jsonl \
--rungs A,C,full \
--bootstrap 10000 --concurrency 4 \
--out report.json--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.