01安装
Python 3.10 以上,Windows / macOS / Linux 都行。基础安装没有任何需要编译的机器学习依赖;向量检索来自 sqlite-vec,它是一个 SQLite 扩展。
pip install facetmark
# 或者用 uv:
uv pip install facetmark
facetmark version带本地嵌入
只有你想在自己机器上算嵌入、而不走端点时才需要。它会拉 PyTorch 和 sentence-transformers,几百 MB。
pip install "facetmark[local]"从源码装
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.db 和 pairing-token.txt。全部安装足迹就这么多,删目录等于卸数据。
没 key 没网也能先试
facetmark demo 会造一个合成库,用确定性的离线 provider 建索引,然后跑三条搜索。首页那个终端就是这么录的。
facetmark demo --size 6002把书签导进来
导入是单向只读的。facetmark 读浏览器配置或者导出文件,两者都不写。
Chromium 系:不用导出
Chrome、Edge、Brave、Vivaldi、Chromium、Opera 和 Opera GX 都把书签放在一个 JSON 文件里,facetmark 能自己找到。浏览器开着也能安全读。
facetmark browsers # 它能看到哪些
facetmark import # 只有一个时直接导装了多个配置时,它不猜 —— 导错人的书签比多敲一条命令糟糕得多。它会把候选列出来,你自己指:
facetmark import "$HOME/.config/google-chrome/Default/Bookmarks"Firefox 和 Safari:先导出 HTML
| 浏览器 | 导出入口 |
|---|---|
| Firefox | 书签 → 管理书签 → 导入和备份 → 将书签导出为 HTML |
| Safari | 文件 → 导出 → 书签 |
| Chrome / Edge(手动路线) | chrome://bookmarks → ⋮ → 导出书签 |
| 其他 | 任何 Netscape 格式的 bookmarks.html 都行。这是 1994 年的格式,到今天所有人还在写它。 |
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_indexable | javascript:、place:、file: 之类。 |
missing_dates | 没有保存时间的。照样导入,但进不了保存会话。 |
privacy_skipped | 被 FACETMARK_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 模型写富化(摘要、主题、实体、要点)和候选意图查询。嵌入模型把页面正文变成向量。
走端点
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这是最常见的配置失败,没之一。少了 /v1,每一次调用都 404,包括第一次;而错误是从 provider 那边回来的,看起来像是密钥问题。
不想设环境变量,可以在运行目录放一个 .env。名字一样,前缀一样。
FACETMARK_API_KEY=sk-...
FACETMARK_BASE_URL=https://api.deepseek.com/v1
FACETMARK_CHAT_MODEL=deepseek-chat共享端点或免费端点
如果端点上一个列出来的模型可能根本不在、可能欠费、也可能不支持 response_format,那就配一条降级链。默认为空是故意的:付费端点报错是在告诉你事情,吠掉它比失败更糟。
export FACETMARK_CHAT_MODEL_FALLBACKS=deepseek-chat,qwen-plusprovider 会记录每一次调用到底是哪个模型答的。任何建立在降级链上的报告,都必须把这个混合比例公开。
本地嵌入,不要 key
用 sentence-transformers 在你自己机器上跑嵌入模型。再把 API key 留空,除了抓页面之外就什么都不出去了。
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同一篇文档嵌入两次,必须落在同一个地方。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建索引
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 个向量。
05搜索
facetmark search "那篇讲把向量存在 sqlite 里的"
facetmark search "sqlite-vec" -n 20 --explain
facetmark search "error EADDRINUSE" --quick| 参数 | 作用 |
|---|---|
-n, --limit | 返回多少条。默认 10。 |
--quick | 只走词面。不调模型、不联网、亚毫秒级。 |
--explain | 打印每条命中的是哪个面。搞清楚「它为什么排在这」最快的办法。 |
--config NAME | 跑指定的 profile 或消融档。默认 full。 |
--json | 机器可读,包含每个阶段的耗时。 |
profile 和消融档
--config 接受任何预注册档、任何出厂 profile,以及大约 20 个探索性消融档。档位的定义在 search/pipeline.py 里。
| 名字 | 面与阶段 | 状态 |
|---|---|---|
A | 只用内容向量 | W1 赢家 · 0.643 |
B | 内容 + 两个词面 | −5.4pp |
C | 四个面全上 | 已实测 |
D | 四面 + 上下文 + 图 | 已实测 |
E | 四面 + 上下文 + 图 + 重排 | 已实测 |
full | 内容 + 图 + 衰减 | 真实 provider 下的默认 |
fused | 四面 + 上下文 + 图 + 重排 + 衰减 | mock provider 下的默认 |
mock 是把文本哈希成向量的,所以内容面 —— 在真实库上完胜的那个 —— 恰好就是在 mock 下返回噪声的那个。在那种部署下再把词面去掉,就什么能用的都不剩了。有真实嵌入的拿实测结论;其他人拿门控之前的行为,至少能按词搜。
排名是怎么构成的
选中的每个面各返回最多 CANDIDATES_PER_FACET 条候选。RRF 按 sum_f w_f / (k + rank_f) 合并,k = 60。然后按顺序跑上下文、衰减、重排;一跳图扩展作为单独一组返回 —— 不混进排名,因为当初测的就是它作为“补充”的效果,不是“替代”。
这是设计如此。重排会重排前 20 条,但故意保留每条上的融合分,所以重排过的列表看起来分数是乱的。如果它把分数覆盖了,你就再也看不到融合当时是怎么想的。
看一次保存会话
facetmark sessions -n 20 # 最近的保存会话
facetmark show 412 --body # 一条书签的 JSON
facetmark stats # 索引规模与覆盖率06起服务:HTTP API 与配对令牌
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 头里。
facetmark token # 打印
facetmark token --rotate # 作废旧的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
| 字段 | 类型 | 含义 |
|---|---|---|
q | string | 查询。必填。 |
limit | int | 返回条数。 |
config | string | profile 或档位名。"" 和 "full" 都走 default_config。 |
assist | bool | 允许模型参与的理解阶段。 |
expand | bool | 同时返回一跳图扩展那一组。 |
全部路由
| 分组 | 路由 |
|---|---|
| 公开 | 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。
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.txtPython 包里的纯 HTML、CSS 和 ES 模块:没有 Node,没有打包器,也就没有会和服务端对不上的构建产物。页面和 API 由同一个进程发出,所以是同源的 —— 这也是它没法托管到别处去的原因:这个服务的 CORS 只对浏览器扩展的来源开放。
两个视图
| 视图 | 地址 | 干什么用 |
|---|---|---|
| 搜索 | /app#/search | 搜索框和排好序的列表。一敲字先出字面匹配的结果,完全不调模型;排好序的答案到了就替换掉,加载更多翻后面的。 |
| 书签库 | /app#/library | facetmark 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
每个搜索入口都收 limit、offset 和 depth,而每个搜索响应报的是它实际给出的那个窗口,不是把你要的原样回显。
facetmark search "kafka rebalance" -n 20
facetmark search "kafka rebalance" -n 20 -o 20 --depth 60只要还有下一页,CLI 就会把下一页的 --offset 和 --depth 打出来。走 HTTP 时,同样这三个字段放在 POST /search 的请求体里:
{
"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 条。现在,页是一个窗口,看的是一个你能看见、也能钉住的候选池。
只有在一个面的时候,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 说话 —— 那就是它全部的必需主机权限。
- 从发行页下载
facetmark-extension.zip并解压。 - 打开
chrome://extensions,开启开发者模式,选加载已解压的扩展,指向刚才那个目录。 - 在终端跑
facetmark serve并保持运行。 - 跑
facetmark token,打开扩展的设置页,把令牌粘进去。 - 按 Ctrl+Shift+K(macOS 是 Cmd+Shift+K)就能搜了。
它能干什么
| 功能 | 说明 |
|---|---|
| 地址栏关键字 | 地址栏输 fm 再敲空格,不用开弹窗就能搜。 |
| 快捷键 | Ctrl+Shift+K / Cmd+Shift+K。 |
| 保存当前标签页 | 一键。页面进本地索引队列,弹窗底部显示还剩几个。 |
| 右键菜单 | 右键一个链接或页面就能存。 |
| 分组结果 | 同一次保存会话里的页面单独成组,不混进排名。 |
| 面标签 | 每条结果显示命中了哪些面 —— 关于、可能会问、词、子串、关联、冷。 |
设置项
| 字段 | 含义 |
|---|---|
endpoint | facetmark 监听在哪。默认 http://127.0.0.1:8787。 |
token | facetmark token 的输出。 |
channelB | 可选的第二个端点,用于同时跑两个库。 |
paused | 不卸载的前提下让扩展停止和服务通信。 |
扩展以 zip 形式放在发行页,需要解压后加载。它没有提交到 Chrome 应用商店或 Edge 加载项目录。
10MCP 服务器
facetmark mcp 在 stdio 上跑一个 FastMCP 服务器,所以 Claude Desktop 这类 MCP 客户端可以搜你的库、读一次保存会话、存一个页面。
{
"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 管检索。
- 把插件拷进 karakeep 的插件包。
- 在 exports 映射里注册它。
- 在 meilisearch 之后加载,因为插件管理器发出去的是最后注册的那个提供者。
- 把它指向一个在跑的 facetmark 服务。
cp -r integrations/karakeep/search-facetmark \
/path/to/karakeep/packages/plugins/search-facetmark// packages/plugins/package.json — exports 映射
"./search-facetmark": "./search-facetmark/index.ts"// packages/shared-server/src/plugins.ts 的 loadAllPlugins()
await import("@karakeep/plugins/search-meilisearch");
await import("@karakeep/plugins/search-facetmark"); // 必须在后面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 了,数据也还读得出来。
| 表 | 存什么 |
|---|---|
bookmark | URL、标题、文件夹路径、保存时间、来源。 |
content | 抓回来的正文和抽取结果。 |
enrichment | 摘要、主题、实体、要点,以及 source_hash 指纹。 |
intent | 生成的候选查询,以及它有没有过了「能搜回来」的过滤。 |
vec_content / vec_intent | sqlite-vec 虚拟表,存稠密向量。 |
fts_tri / fts_seg | 两个 FTS5 索引:字符三元组和词段。 |
session / bookmark_session | 重建出来的保存会话及其成员。 |
edge | 带类型的边:session、semantic、same_domain、supersession。 |
health | 链接健康结论:ok、gone、drifted、soft_gone。 |
karakeep_doc | 桥的状态。drop 掉就是卸载。 |
meta | 嵌入模型、维度、后端,第一次建索引时写入,之后强制校验。 |
链接健康与冷层
facetmark health # 已知的
facetmark health --check # 真的去探网络
facetmark health --check --no-save-recovered # 只读扫描扫描可以用 DNS-over-HTTPS、Wayback 可用性 API 和一个阅读代理,来区分「页面没了」和「你 DNS 坏了」。在拿这个库做任何测量之前,请加 --no-save-recovered,让扫描除了健康日志之外保持只读。
冷层把「URL 死了」当成「存下来的副本没用了」,这是错的:facetmark 存了正文。URL 死了恰恰是本地快照最值钱的时候。现在还没修,因为在出厂 profile 下另一个意外让这个降权根本没机会执行,而只拆掉其中任一个,结果会实测变差 1.46pp。完整的故事。
13全部配置项
作为环境变量时,每个名字前面加 FACETMARK_;放在 .env 里也一样。下面的默认值就是出厂值。
存储
| 配置项 | 默认 | 说明 |
|---|---|---|
DATA_DIR | 按系统 | 见安装。 |
DB_NAME | facetmark.db | |
PRIVACY_EXCLUDED_DOMAINS | 空 | 不导入、不抓、不嵌入。 |
模型接入
| 配置项 | 默认 | 说明 |
|---|---|---|
API_KEY | 空 | 空是合法的,代价是失去内容面和意图面。 |
BASE_URL | https://api.openai.com/v1 | 必须以 /v1 结尾。 |
CHAT_MODEL | gpt-4o-mini | |
CHAT_MODEL_FALLBACKS | 空 | 逗号分隔。默认为空是故意的。 |
EMBED_MODEL | text-embedding-3-small | |
EMBED_DIM | 1536 | 写进 meta,对不上就报错。 |
EMBED_BACKEND | endpoint | 或 local。 |
REQUEST_TIMEOUT | 60.0 | 秒。 |
MAX_RETRIES | 3 | |
USE_MOCK_PROVIDER | false | 确定性离线 provider。 |
本地嵌入
| 配置项 | 默认 | 说明 |
|---|---|---|
LOCAL_EMBED_PATH | 空 | 空就下载。 |
LOCAL_EMBED_DEVICE | cpu | |
LOCAL_EMBED_BATCH | 8 | |
LOCAL_EMBED_MAX_SEQ | 1024 | 调低会损失可重现性 —— 见模型接入。 |
抓取
| 配置项 | 默认 | 说明 |
|---|---|---|
FETCH_CONCURRENCY | 30 | 全局。 |
FETCH_PER_HOST_CONCURRENCY | 2 | 礼貌,不是性能。 |
FETCH_PER_HOST_MIN_INTERVAL | 0.5 | 同一主机两次请求的秒数间隔。 |
FETCH_TIMEOUT | 15.0 | |
RESPECT_ROBOTS | true | |
ROBOTS_ON_ERROR | allow | robots.txt 读不到时怎么办。 |
ROBOTS_MAX_CRAWL_DELAY | 5.0 | 对对方声明的 crawl delay 封顶。 |
MIN_BODY_CHARS | 200 | 低于此数算无正文。 |
BODY_TRUNCATE_CHARS | 6000 | |
USER_AGENT | 写明自己是 facetmark |
富化与意图
| 配置项 | 默认 | 说明 |
|---|---|---|
ENRICH_CONCURRENCY | 4 | |
INTENT_GENERATE_N | 8 | 每页生成多少候选。 |
INTENT_KEEP_N | 4 | 每页最多留多少。 |
INTENT_PROBE_TOP_K | 10 | 「能不能搜回来」看多深。 |
会话、检索与衰减
| 配置项 | 默认 | 说明 |
|---|---|---|
SESSION_EPS_MINUTES | 自动 | 不设时,间隔由覆盖率 × 纯度提升在网格上选。 |
SESSION_EPS_GRID_MINUTES | 5…240 | 搜索的网格。 |
RRF_K | 60 | w / (k + rank) 里的 k。 |
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 | 改它之前先看衰减层实测。 |
链接健康与服务
| 配置项 | 默认 | 说明 |
|---|---|---|
HEALTH_ENABLE_EXTERNAL | true | 网络探测总开关。 |
HEALTH_ENABLE_DOH | true | DNS-over-HTTPS。 |
HEALTH_ENABLE_WAYBACK | true | |
HEALTH_ENABLE_READER | true | |
HEALTH_SOFT_GONE_LENGTH_RATIO | 0.30 | 正文缩到这个比例 ⇒ soft_gone。 |
HEALTH_GONE_CONFIRM_DAYS | 7 | |
HEALTH_PROXY_URL | 未设 | |
HOST | 127.0.0.1 | |
PORT | 8787 |
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 检验。
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 stats 和 facetmark health 会把索引里到底有什么打出来,大部分困惑到这就解了。实在不行就提个 issue —— 最有用的是把出错那条命令的 --json 输出贴上来。