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.
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,700+ 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/ |
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.
facetmark demo --size 60See 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.
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.
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
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=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-flashShared 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.
export FACETMARK_CHAT_MODEL_FALLBACKS=deepseek-flash,deepseek-v4-proThe 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.
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=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.
See also: Models and configuration
Build the index#
facetmark indexIndexing 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.
| 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
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
Search#
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 coverageThe 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.
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| Field | Matches | Example |
|---|---|---|
domain: alias site: | the site, exact or wildcard | domain:github.com |
host: | the full hostname | host:news.ycombinator.com |
url: | part of the address | url:*/docs/* |
title: | words in the title only | title:encryption |
text: | words in the fetched page body | text:"GDPR compliance" |
folder: | the browser folder it came from | folder:study |
tag: | one of your own tags, exactly | tag:work |
topic: | a topic the enrichment wrote | topic:postgres |
lang: | the detected page language | lang:zh |
opened: | how many times you opened it | opened:10.. |
Negation, phrases, alternation, wildcards
-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 charactersDates
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.
| Written | Means |
|---|---|
added:<7d | saved in the last week |
added:>90d | older than 90 days |
added:2026-04 | saved that month |
added:2026-04-01..2026-09-01 | an explicit range |
before:2026-05-01 after:30d | aliases 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.
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#
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
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.
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 |
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.
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
| View | Address | What it is for |
|---|---|---|
| Search | /app#/search | Committed 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#/ask | Submit a question to generate an answer from stored summaries or snippets, with numbered citations. Follow the sources and verify the original pages. |
| Library | /app#/library | Inspect 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#/sessions | Find bookmarks saved together and open a session to continue reading. |
| System | /app#/system | Inspect service and model status, fetch queues, link health and the cold-layer list. |
| Settings (gear) | /app#/settings | Test and save model configuration, adjust indexing options, and start or cancel an index job. |
| First run | /app#/setup | Import bookmarks, choose models and build the index. You can revisit these steps with an existing library. |
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.
| Marker | Means | Default |
|---|---|---|
| about | The content facet matched — an embedding of stored content, which can include a title-derived summary. | 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 |
|---|---|
| / | Open and focus Search when you are not typing in another field. |
| Enter | Submit 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. |
| Esc | Close 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.
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 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.
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.
- 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.
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.
{
"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.
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.
- 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.
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.
| 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.
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
| 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-6-luna | |
CHAT_EXTRA_BODY | JSON object string for reasoning and output limits. Empty uses endpoint defaults without forced sampling. | |
EMBED_SEND_DIMENSIONS | false | dimensions |
EMBED_BATCH_SIZE | 64 | Use 20 for Bailian. |
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 |
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.
| 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. | |
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 |
doctor | Diagnose 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 |
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 |
crawl URL | Walk 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 |
update | Say 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 |
demo | Build a synthetic library offline and search it. | --size, --keep |
config path / config show | Where config.toml lives, and the effective settings with the source of each one. | |
eval | Run 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.
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.
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