使用指南

四条命令从安装到第一次搜索,然后是其余全部:四个浏览器的导入、两种接模型的方式、HTTP API、浏览器扩展、MCP、karakeep 插件,以及全部配置项和全部命令。

01安装

Python 3.10 以上,Windows / macOS / Linux 都行。基础安装没有任何需要编译的机器学习依赖;向量检索来自 sqlite-vec,它是一个 SQLite 扩展。

shell
pip install facetmark
# 或者用 uv:
uv pip install facetmark

facetmark version

带本地嵌入

只有你想在自己机器上算嵌入、而不走端点时才需要。它会拉 PyTorch 和 sentence-transformers,几百 MB。

shell
pip install "facetmark[local]"

从源码装

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

pytest -q                 # 1,514 个测试
ruff check src tests scripts
不要格式化代码库

它是手写排版的。CI 跑的是 ruff check,不是 ruff format;跑后者会生成一份没人想 review 的 diff。

数据存在哪

一个目录,按平台选,里面一个 SQLite 文件。用 FACETMARK_DATA_DIR 换目录,或者用 --db 给单条命令指一个文件。

平台默认数据目录
Windows%LOCALAPPDATA%\facetmark\
Linux / macOS~/.local/share/facetmark/
设了 XDG_DATA_HOME$XDG_DATA_HOME/facetmark/

里面是 facetmark.dbpairing-token.txt。全部安装足迹就这么多,删目录等于卸数据。

没 key 没网也能先试

facetmark demo 会造一个合成库,用确定性的离线 provider 建索引,然后跑三条搜索。首页那个终端就是这么录的。

shell
facetmark demo --size 60

02把书签导进来

导入是单向只读的。facetmark 读浏览器配置或者导出文件,两者都不写。

Chromium 系:不用导出

Chrome、Edge、Brave、Vivaldi、Chromium、Opera 和 Opera GX 都把书签放在一个 JSON 文件里,facetmark 能自己找到。浏览器开着也能安全读。

shell
facetmark browsers        # 它能看到哪些
facetmark import          # 只有一个时直接导

装了多个配置时,它不猜 —— 导错人的书签比多敲一条命令糟糕得多。它会把候选列出来,你自己指:

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

Firefox 和 Safari:先导出 HTML

浏览器导出入口
Firefox书签 → 管理书签 → 导入和备份 → 将书签导出为 HTML
Safari文件 → 导出 → 书签
Chrome / Edge(手动路线)chrome://bookmarks → ⋮ → 导出书签
其他任何 Netscape 格式的 bookmarks.html 都行。这是 1994 年的格式,到今天所有人还在写它。
shell
facetmark import ~/Downloads/bookmarks.html

导入会报什么

同一条命令同时处理 Netscape HTML 和 Chrome JSON,并且报它干了什么,而不是转一个圈。在一份真实的 1.7 MB 导出上(96 个文件夹、四层嵌套):解析 1,710 条,写入 1,701 条,合并 9 条重复,1 条不可索引。

字段含义
parsed文件里找到的条目数。
inserted / updated新写入的,以及标题或文件夹变了的。
merged_duplicates同一个 URL 存了两次,取时间早的那个。
non_indexablejavascript:place:file: 之类。
missing_dates没有保存时间的。照样导入,但进不了保存会话。
privacy_skippedFACETMARK_PRIVACY_EXCLUDED_DOMAINS 挡下的。
timestamp_unit源文件用的是哪种时间戳。Chrome 和 Netscape 不一样,这里告诉你识别出的是哪种。
先排除域名,再导入

FACETMARK_PRIVACY_EXCLUDED_DOMAINS 设成逗号分隔的列表,这些主机就不会被写入、不会被抓、也不会被嵌入。比事后删行省事。

03模型接入

facetmark 只通过一个 OpenAI 兼容端点访问模型。代码里故意没有任何针对具体厂商的分支:一个 base_url 加一个 api_key 就覆盖 OpenAI、DeepSeek、Kimi、智谱、硅基流动、阿里百炼、together.ai、Azure OpenAI、Ollama、vLLM、LM Studio,以及任何说同一套协议的内部网关。

用到两个角色。chat 模型写富化(摘要、主题、实体、要点)和候选意图查询。嵌入模型把页面正文变成向量。

走端点

shell
export FACETMARK_API_KEY=sk-...
export FACETMARK_BASE_URL=https://api.openai.com/v1
export FACETMARK_CHAT_MODEL=gpt-4o-mini
export FACETMARK_EMBED_MODEL=text-embedding-3-small
export FACETMARK_EMBED_DIM=1536
base URL 必须以 /v1 结尾

这是最常见的配置失败,没之一。少了 /v1,每一次调用都 404,包括第一次;而错误是从 provider 那边回来的,看起来像是密钥问题。

不想设环境变量,可以在运行目录放一个 .env。名字一样,前缀一样。

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

共享端点或免费端点

如果端点上一个列出来的模型可能根本不在、可能欠费、也可能不支持 response_format,那就配一条降级链。默认为空是故意的:付费端点报错是在告诉你事情,吠掉它比失败更糟。

shell
export FACETMARK_CHAT_MODEL_FALLBACKS=deepseek-chat,qwen-plus

provider 会记录每一次调用到底是哪个模型答的。任何建立在降级链上的报告,都必须把这个混合比例公开。

本地嵌入,不要 key

sentence-transformers 在你自己机器上跑嵌入模型。再把 API key 留空,除了抓页面之外就什么都不出去了。

shell
pip install "facetmark[local]"

export FACETMARK_EMBED_BACKEND=local
export FACETMARK_EMBED_MODEL=bge-m3
export FACETMARK_EMBED_DIM=1024
export FACETMARK_LOCAL_EMBED_PATH=/path/to/bge-m3   # 不设就下载
export FACETMARK_LOCAL_EMBED_MAX_SEQ=1024
为什么序列长度默认是 1024

同一篇文档嵌入两次,必须落在同一个地方。bge-m3 在 1024 token 下,一个固定的 64 篇探针集上最小自余弦是 0.999976,64/64 全部自匹配。降到 512 token,最小值掉到 0.9769 —— 因为截断开始从同一段文本上剪掉不同的量。所以默认是 1024,而且调低它是一笔真的交易。

改维度等于作废所有向量

FACETMARK_EMBED_DIM 在第一次建索引时写进 meta 表。之后对不上就报错,而不是默默把不兼容的向量混在一起。换嵌入模型或换维度,请用 facetmark index --force 重算。

一个模型都不接

照装照跑。你保留两个词面、保存会话、域名与链接图、链接健康。你失去内容面和意图面。facetmark search --quick 是明确的纯词面路径,一次模型调用都不发。

04建索引

shell
facetmark index

一条命令按顺序跑完所有阶段。每个阶段都幂等且带指纹,所以新增 50 条书签后再跑,它只做这 50 条的活,不是整个库。

阶段干什么要模型吗?
fetch抓页面,遵守 robots.txt 和单域名限速,抽取可读正文。不要
enrich摘要、主题、实体、要点 —— 每页一次小的 chat 调用。chat
embed_content把重建后的文本嵌入。嵌入
intents为每页生成候选查询。chat
filter_intents只留下能把这页搜回来的那些。典型情况下不到一半能活。不要
embed_intents把存活下来的意图嵌入。嵌入
sessions按时间间隔把保存行为聚成一次次会话,间隔选哪个由「覆盖率 × 相对打乱对照的纯度提升」决定。不要
edges建会话边、语义边、同域名边和替代边。不要

常用参数

参数作用
--no-fetch完全不抓网,只索引标题。几秒钟而不是几小时,效果也弱很多。
--limit N每阶段只处理 N 条。适合先看一眼这一跑到底要多少钱。
--force不看指纹,已经做过的也重做。
--mock确定性离线 provider。不要 key、不联网、也没质量。
--json每个阶段的机器可读报告,含各阶段耗时。

指纹是怎么算的

  • 富化按正文哈希。正文没变,就不发第二次 chat 请求。
  • 嵌入重建后的嵌入文本,而不是按正文。所以富化变了、嵌入文本跟着变了,那个陈旧向量会被发现 —— karakeep 往返的损伤就是这么插出来的。
  • 会话和边每次重建;它们便宜,而且依赖整个库。

facetmark reindex 把所有衍生产物丢掉,从书签本身重建。facetmark migrate 把旧库升到当前 schema,默认先快照一份,除非你加 --no-backup

这一跑的代价

钱主要在富化:大致每页一次小的 chat 调用,所以 1,700 页的库用便宜模型是几毛钱。壁钟时间主要在抓页面,而抓页面是故意慢的 —— FETCH_PER_HOST_CONCURRENCY 是 2,同一主机两次请求之间还有最小间隔。

给个量级感:刚才那个真实的 1,700 条书签库,用 --no-fetch 建索引,得到 322 个保存会话、9,132 条边、1,386 个域名、1,775 个向量。

06起服务:HTTP API 与配对令牌

shell
facetmark serve        # 127.0.0.1:8787
只听回环地址不算鉴权

除了 //health,以及本地页面加载自己用的 /app/app/boot,每条路由都要令牌,在 localhost 上也要 —— 因为你机器上任何一个进程都能访问 127.0.0.1。--host 不是回环地址时,facetmark serve 会告警:这个索引里是你整个浏览兴趣图谱。

令牌

首次运行时生成,写在数据目录的 pairing-token.txt 里。请求时放在 x-facetmark-token 头里。

shell
facetmark token             # 打印
facetmark token --rotate    # 作废旧的
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

字段类型含义
qstring查询。必填。
limitint返回条数。
configstringprofile 或档位名。"""full" 都走 default_config
assistbool允许模型参与的理解阶段。
expandbool同时返回一跳图扩展那一组。

全部路由

分组路由
公开GET / · GET /health
本地页面 —— 同样公开GET /app · GET /app/static/* · GET /app/boot
搜索GET /stats · GET /quick · POST /search · POST /suggest · POST /synthesize
记录GET /bookmark/{id} · GET /bookmark/{id}/related · POST /bookmark · POST /open
会话GET /sessions · GET /session/{id}
索引队列GET /queue/next · POST /queue/complete · GET /queue/stats
链接健康GET /link-health/summary · GET /link-health/{id} · POST /link-health/check · GET /graveyard
karakeep 桥POST /karakeep/documents · POST /karakeep/documents/delete · POST /karakeep/search · POST /karakeep/clear · GET /karakeep/stats

07本地页面

facetmark serve 同时还托管着一个搜索页。它是唯一一个除了 facetmark 本身什么都不用装的入口 —— 不用加载浏览器扩展,不用配编辑器,也不用 curl

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

Python 包里的纯 HTML、CSS 和 ES 模块:没有 Node,没有打包器,也就没有会和服务端对不上的构建产物。页面和 API 由同一个进程发出,所以是同源的 —— 这也是它没法托管到别处去的原因:这个服务的 CORS 只对浏览器扩展的来源开放。

两个视图

视图地址干什么用
搜索/app#/search搜索框和排好序的列表。一敲字先出字面匹配的结果,完全不调模型;排好序的答案到了就替换掉,加载更多翻后面的。
书签库/app#/libraryfacetmark stats 打印的所有东西,按行列出来:书签数、多少条抓到了正文、多少条做了向量、会话、按类型分的边、抓取队列、链接健康,还有冷层清点。“我搜了但什么都没有” 这个问题就靠这个视图回答。
它故意不做的事

它只读。没有删除,没有编辑,没有队列控制,也没有综述按钮。那些在命令行和 API 里有,在那儿犯错至少是主动犯的。页面唯一写的一次,是你点开某条结果时的 POST /open,冷层就是靠它喂的。

结果行上的标记是什么意思

和扩展弹窗用的是同一套词。在页面里,每个标记鼠标悬停都有一行解释;这张表是为了让你一次看全。

标记意思默认
内容相关命中了内容面 —— 页面自己正文的向量。
提问方式命中了意图面 —— 给这个页面生成的问题的向量。
词语命中了字面 · 分词面 —— 对标题、文件夹、网址里完整词的 FTS5。
子串命中了字面 · 三元组面 —— 对字符的 FTS5,中文查询和只打了一半的词能命中,靠的就是它。
已冷却很久以前存的,一直没打开过,而且有更新的东西看起来把它取代了。排名压低,绝不删除。
当时前后一起存的第二组:从上面某条结果出发,在链接图上走一跳。绝不混进排名里。

第二组里每一行都带着走到它的那条边 —— 同一次浏览(同一次上网时存的)、语义相近已被取代同一页面同一站点。这些名字背后的权重在配置表里。

页面怎么拿到令牌

它去问 GET /app/boot。这是唯一一条能把配对令牌交出去的路由,而且只在调用方和请求里写的地址两者都是回环地址时才交。在你自己机器上两条都成立,页面就自己配对好了,没有什么要复制的。

第二个条件是干什么的

公网上的一个页面可以把某个域名解析到 127.0.0.1,然后让你的浏览器去发这个请求 —— 调用方确实是回环地址。但它改不了 Host 头,那里面还写着攻击者的域名。查这一项,才是拦住一个网站读走你令牌的东西;这也是为什么它是一条单独的路由,而不是挂在现有路由上的一个开关。

在反向代理后面,或者用局域网地址访问时,这个检查会不通过 —— 这是故意的:页面这时给你一个输入框,把 facetmark token 粘一次就行。它存在那个浏览器的本地存储里,不在页面里。

键盘

按键作用
/在页面任何地方聚焦到搜索框。
Enter搜索。
在结果之间移动。在搜索框里按 进入列表。
Esc清空查询,回到搜索框。

语言和主题

中英文,顶栏切换,下次记得。没存过选择时跟随浏览器语言。主题开关在 跟随系统 → 浅色 → 深色 之间循环,而且和本站共用同一个存储键,所以在这里选了深色的人,那边也是深色。所有动效都包在 prefers-reduced-motion 查询里。

08翻页:limit、offset 和 depth

每个搜索入口都收 limitoffsetdepth,而每个搜索响应报的是它实际给出的那个窗口,不是把你要的原样回显。

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

只要还有下一页,CLI 就会把下一页的 --offset--depth 打出来。走 HTTP 时,同样这三个字段放在 POST /search 的请求体里:

json
{
  "hits": [ ],
  "limit": 20,          // 实际给的,已经夹过
  "offset": 20,
  "depth": 60,          // 这次排名跑的深度
  "total": 137,         // 已经排过的条数;封顶时是下界
  "has_more": true,
  "depth_capped": false
}
字段含义
limit这一页的条数。会夹到 MAX_PAGE_SIZE,默认 200。
offset跳过的条数。会夹在 MAX_CANDIDATE_DEPTH 以下。
depth融合之前每个面各读多深。不填就按窗口推算;把上一页报的值原样送回来,这一页就接着同一次排名往下走。
total融合这一步排过的文档数。是个下界,不是书签库的总数;depth_capped 为真时更是明确只当下界看。
has_more这个窗口后面还有东西。在出厂的单面默认档下是准的;开了好几个面时是上界 —— 多出来的那一条有可能是候选池里已经有的文档。
depth_capped后面确实还有,而且停下来的原因是撞到了深度上限,不是你的窗口 —— 这是“点下一页”和“把深度调大,或者把查询收窄”之间的区别。

为什么 depth 是个参数,而不是实现细节

以前页大小和检索深度是同一个数:要更多条就会悄悄检索得更深,而第 51 条在任何页大小下都够不着,因为候选池不管怎样都是 50 条。现在,页是一个窗口,看的是一个你能看见、也能钉住的候选池。

钉住 depth,否则第二页会和第一页打架

只有在一个面的时候,RRF 才在候选池变大时保持名次稳定。一个文档的分数,是它在“深度以内排到了它”的那些面上求和,所以更深的池子可能凭空给某个文档补上一项 —— 而这一项可能压过对手的整个分数。在一个面上排第 2、在另一个面上排第 40,合起来赢过只在一个面上排第 1 的(1/62 + 1/100 对 1/61),但在深度 30 时后面那一项根本不存在。

所以开了好几个面时,为了翻到第 2 页而把深度加大,会让第 2 页对“第 1 页是什么”这件事和第 1 页产生分歧。解法不是加大它:把上一页报的 depth 原样送回来,每一页就都是同一次排名的一个切片。本地页面和浏览器扩展都是这么做的。

两个上限

MAX_PAGE_SIZE(200)限住一页。MAX_CANDIDATE_DEPTH(2000)限住它们背后的整个候选池,撞上它就是 depth_capped 被置上的原因。两个都在同一个地方、在任何查询开跑之前夹好,所以一个超大的请求不花什么代价,回给你的就是实际给出的那个窗口。

09浏览器扩展

Manifest V3,Chromium 系浏览器。它只和 127.0.0.1:8787 说话 —— 那就是它全部的必需主机权限。

  1. 发行页下载 facetmark-extension.zip 并解压。
  2. 打开 chrome://extensions,开启开发者模式,选加载已解压的扩展,指向刚才那个目录。
  3. 在终端跑 facetmark serve 并保持运行。
  4. facetmark token,打开扩展的设置页,把令牌粘进去。
  5. Ctrl+Shift+K(macOS 是 Cmd+Shift+K)就能搜了。

它能干什么

功能说明
地址栏关键字地址栏输 fm 再敲空格,不用开弹窗就能搜。
快捷键Ctrl+Shift+K / Cmd+Shift+K
保存当前标签页一键。页面进本地索引队列,弹窗底部显示还剩几个。
右键菜单右键一个链接或页面就能存。
分组结果同一次保存会话里的页面单独成组,不混进排名。
面标签每条结果显示命中了哪些面 —— 关于可能会问子串关联

设置项

字段含义
endpointfacetmark 监听在哪。默认 http://127.0.0.1:8787
tokenfacetmark token 的输出。
channelB可选的第二个端点,用于同时跑两个库。
paused不卸载的前提下让扩展停止和服务通信。
不在应用商店里

扩展以 zip 形式放在发行页,需要解压后加载。它没有提交到 Chrome 应用商店或 Edge 加载项目录。

10MCP 服务器

facetmark mcp 在 stdio 上跑一个 FastMCP 服务器,所以 Claude Desktop 这类 MCP 客户端可以搜你的库、读一次保存会话、存一个页面。

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

args 里加 "--db", "/path/to/facetmark.db" 可以指定库,加 "--mock" 可以没 key 先试。环境变量的读法和其他命令完全一样。

9 个工具

工具作用
search_bookmarks完整管线,和 facetmark search 一样。
get_bookmark一条记录,可选带正文。
list_sessions最近的保存会话。
get_session一次会话里存的全部。
find_related在链接图里往外走一跳。
synthesize基于检索到的页面写一份回答。
suggest_from_context你正在看的这段文字,库里有什么相关。
check_link_health一个存过的 URL 还活着吗。
save_bookmark加一个 URL 并排队索引。

3 个资源

  • bookmark://{id} —— 一条记录的 JSON。
  • session://{id} —— 一次保存会话。
  • facetmark://stats —— 索引规模与覆盖率。

11karakeep 插件

karakeep 是一个可自托管的书签管理器,搜索提供者可插拔。这个插件把 facetmark 接到它的搜索框后面:karakeep 管界面,facetmark 管检索。

  1. 把插件拷进 karakeep 的插件包。
  2. 在 exports 映射里注册它。
  3. 在 meilisearch 之后加载,因为插件管理器发出去的是最后注册的那个提供者。
  4. 把它指向一个在跑的 facetmark 服务。
shell
cp -r integrations/karakeep/search-facetmark \
  /path/to/karakeep/packages/plugins/search-facetmark
json
// packages/plugins/package.json — exports 映射
"./search-facetmark": "./search-facetmark/index.ts"
ts
// packages/shared-server/src/plugins.ts 的 loadAllPlugins()
await import("@karakeep/plugins/search-meilisearch");
await import("@karakeep/plugins/search-facetmark");  // 必须在后面
shell
export FACETMARK_URL=http://127.0.0.1:8787
export FACETMARK_TOKEN=$(facetmark token)
facetmark serve

协议是怎么钉住的

  • karakeep 上游的类型按 blob SHA 钉在 integrations/karakeep/typecheck/upstream-pins.json,CI 会对着它跑 tsc --noEmit
  • 报文格式存在 integrations/karakeep/contract/wire.json,由 tests/test_karakeep_contract.py 回放。
  • 这个回放测试真的接住了一个:只有一条命中时的 offset 1,正确答案是 hits: []totalHits: 1。空的 hits 不等于没有结果。
指望它之前要知道两件事

第一,没有针对真实在跑的 karakeep 实例的测试 —— 只有对钉死协议的。第二,把库推进 karakeep 再读回来,排名会变:karakeep 的 tag 就是你浏览器的文件夹名,所以关键词从 19,016 个不同词塌到 13 个。指标层面的结论能过往返,名次层面的不能,除非重建索引。完整实测

想卸掉这座桥,把 karakeep_doc 表 drop 了就行。enrichment.source_hash == 'karakeep' 是保留值,意思是这行桥可以覆写;其他任何值都意味着是真模型写的,桥不碰。

12数据库里有什么

一个 SQLite 文件。任何 SQLite 工具都能打开,不加密、不混淆、不私有。就算你不用 facetmark 了,数据也还读得出来。

存什么
bookmarkURL、标题、文件夹路径、保存时间、来源。
content抓回来的正文和抽取结果。
enrichment摘要、主题、实体、要点,以及 source_hash 指纹。
intent生成的候选查询,以及它有没有过了「能搜回来」的过滤。
vec_content / vec_intentsqlite-vec 虚拟表,存稠密向量。
fts_tri / fts_seg两个 FTS5 索引:字符三元组和词段。
session / bookmark_session重建出来的保存会话及其成员。
edge带类型的边:sessionsemanticsame_domainsupersession
health链接健康结论:okgonedriftedsoft_gone
karakeep_doc桥的状态。drop 掉就是卸载。
meta嵌入模型、维度、后端,第一次建索引时写入,之后强制校验。

链接健康与冷层

shell
facetmark health                       # 已知的
facetmark health --check               # 真的去探网络
facetmark health --check --no-save-recovered   # 只读扫描

扫描可以用 DNS-over-HTTPS、Wayback 可用性 API 和一个阅读代理,来区分「页面没了」和「你 DNS 坏了」。在拿这个库做任何测量之前,请加 --no-save-recovered,让扫描除了健康日志之外保持只读。

一个已知的、而且承重的 bug

冷层把「URL 死了」当成「存下来的副本没用了」,这是错的:facetmark 存了正文。URL 死了恰恰是本地快照值钱的时候。现在还没修,因为在出厂 profile 下另一个意外让这个降权根本没机会执行,而只拆掉其中任一个,结果会实测变差 1.46pp。完整的故事

13全部配置项

作为环境变量时,每个名字前面加 FACETMARK_;放在 .env 里也一样。下面的默认值就是出厂值。

存储

配置项默认说明
DATA_DIR按系统安装
DB_NAMEfacetmark.db
PRIVACY_EXCLUDED_DOMAINS不导入、不抓、不嵌入。

模型接入

配置项默认说明
API_KEY空是合法的,代价是失去内容面和意图面。
BASE_URLhttps://api.openai.com/v1必须以 /v1 结尾。
CHAT_MODELgpt-4o-mini
CHAT_MODEL_FALLBACKS逗号分隔。默认为空是故意的。
EMBED_MODELtext-embedding-3-small
EMBED_DIM1536写进 meta,对不上就报错。
EMBED_BACKENDendpointlocal
REQUEST_TIMEOUT60.0秒。
MAX_RETRIES3
USE_MOCK_PROVIDERfalse确定性离线 provider。

本地嵌入

配置项默认说明
LOCAL_EMBED_PATH空就下载。
LOCAL_EMBED_DEVICEcpu
LOCAL_EMBED_BATCH8
LOCAL_EMBED_MAX_SEQ1024调低会损失可重现性 —— 见模型接入

抓取

配置项默认说明
FETCH_CONCURRENCY30全局。
FETCH_PER_HOST_CONCURRENCY2礼貌,不是性能。
FETCH_PER_HOST_MIN_INTERVAL0.5同一主机两次请求的秒数间隔。
FETCH_TIMEOUT15.0
RESPECT_ROBOTStrue
ROBOTS_ON_ERRORallowrobots.txt 读不到时怎么办。
ROBOTS_MAX_CRAWL_DELAY5.0对对方声明的 crawl delay 封顶。
MIN_BODY_CHARS200低于此数算无正文。
BODY_TRUNCATE_CHARS6000
USER_AGENT写明自己是 facetmark

富化与意图

配置项默认说明
ENRICH_CONCURRENCY4
INTENT_GENERATE_N8每页生成多少候选。
INTENT_KEEP_N4每页最多留多少。
INTENT_PROBE_TOP_K10「能不能搜回来」看多深。

会话、检索与衰减

配置项默认说明
SESSION_EPS_MINUTES自动不设时,间隔由覆盖率 × 纯度提升在网格上选。
SESSION_EPS_GRID_MINUTES5…240搜索的网格。
RRF_K60w / (k + rank) 里的 k
CANDIDATES_PER_FACET50
GRAPH_EXPAND_HOPS1
GRAPH_EXPAND_FACTOR0.6
DECAY_FACTOR0.5
DECAY_AGE_DAYS365
DECAY_RESCUE_THRESHOLD0.02改它之前先看衰减层实测

链接健康与服务

配置项默认说明
HEALTH_ENABLE_EXTERNALtrue网络探测总开关。
HEALTH_ENABLE_DOHtrueDNS-over-HTTPS。
HEALTH_ENABLE_WAYBACKtrue
HEALTH_ENABLE_READERtrue
HEALTH_SOFT_GONE_LENGTH_RATIO0.30正文缩到这个比例 ⇒ soft_gone
HEALTH_GONE_CONFIRM_DAYS7
HEALTH_PROXY_URL未设
HOST127.0.0.1
PORT8787

14全部命令

每条命令都有 --db,可以指向具体的数据库文件或数据目录。大部分都支持 --json

命令作用值得一提的参数
version打印版本。
browsers列出可导入的活浏览器配置。--json
import [PATH]导入 Netscape HTML 或 Chrome JSON。不带路径时自动找活配置。从不写回。
migrate把 schema 升到当前构建需要的版本。--check--no-backup
index抓取、富化、嵌入、意图、会话、边。--no-fetch--limit--force--mock
reindex从书签重建所有衍生产物。--mock
search QUERY搜库。-n--quick--config--explain
show ID把一条书签打成 JSON。--body
sessions列出保存会话。-n
health链接健康,以及衰减层到底看不看得见它。--check--no-external--no-save-recovered
stats索引规模与覆盖率。
token打印扩展要的配对令牌。--rotate
serve跑本地 HTTP 服务。--host--port--mock
mcp在 stdio 上跑 MCP 服务器。--mock
demo离线造一个合成库并搜它。--size--keep
eval跑检索评测,可以是 A–E 消融。--ablation--rungs--queries--bootstrap--out

跑你自己的评测

这是 facetmark 最重要、也是至今没有第二个人用过的部分。给它一个 {text, qtype, target_url} 的 JSONL,它就能在你自己的库上跑任意一组档位,给出 bootstrap 置信区间和配对差异的 McNemar 检验。

shell
facetmark eval --no-build \
  --queries my-queries.jsonl \
  --rungs A,C,full \
  --bootstrap 10000 --concurrency 4 \
  --out report.json
并发会摧毁延迟数字

--concurrency > 1 会让 p50 和 p95 失去意义。要质量数字时用它,要延迟就抽一个子集在并发 1 下重跑。

15排错

每次模型调用都返回 404

base URL 漏了 /v1。这是差距很大的第一名配置失败,而且错误以 provider error 的形式冒出来,看起来很像密钥不对。

建索引或搜索时报「维度不匹配」

第一次建库时记在 meta 里的嵌入维度,和当前的 FACETMARK_EMBED_DIM 对不上了。facetmark 宁可报错也不混维度。要么改回去,要么用 facetmark index --force 重算全部向量。

富化静悄悄地什么也没做

存着的 source_hash 已经等于当前正文哈希,指纹认为活干完了。这是正确行为,facetmark index --force 可以覆盖它。

向量有,但结果很差

通常是向量写完之后嵌入文本又变了 —— 比如富化被一座桥覆写了。用 facetmark index --force 重算。如果是一个全新的索引就差,先确认自己是不是不小心跑在 mock provider 上:facetmark stats 会报当前用的嵌入模型。

SQLite 报 disk I/O error

SQLite 在某些网络文件系统和 FUSE 上跑不稳。用 FACETMARK_DATA_DIR 把数据目录换到本地磁盘。

抓得很慢,或者页面是空的

两个通常都是故意的。遵守 robots.txt,单主机并发封顶 2,两次请求之间还有最小间隔。有些站就是不给。没正文的页面照样建索引 —— 管线会退到只用标题的指纹 —— 只是弱一些。想要快而浅的索引,用 --no-fetch

扩展连不上服务

按顺序查三件事:facetmark serve 真的在跑吗;设置里的 endpoint 和它实际监听的主机端口对得上吗;设置里的令牌和 facetmark token 一致吗。轮换过令牌的话,扩展要拿新的。

其他

facetmark statsfacetmark health 会把索引里到底有什么打出来,大部分困惑到这就解了。实在不行就提个 issue —— 最有用的是把出错那条命令的 --json 输出贴上来。