安装#
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,700+ 个测试
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 stats 和 facetmark config path 的输出为准。
没 key 没网也能先试
facetmark demo 会造一个合成库,用确定性的离线 provider 建索引,然后跑三条搜索。首页那个终端就是这么录的。
facetmark demo --size 60另见: 快速上手
把书签导进来#
导入是单向只读的。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 设成逗号分隔的列表,这些主机就不会被写入、不会被抓、也不会被嵌入。比事后删行省事。
另见: 快速上手
模型接入#
在线模型共用一个 OpenAI 兼容的 base_url 和 api_key。确认端点同时提供需要的对话与向量能力;兼容网关、自托管服务或本地向量后端可以补足服务商缺少的能力。
对话模型生成摘要、主题、实体、要点和候选问题;向量模型把页面文本与查询转换为向量。没有正文时,部分衍生内容可能依据标题生成。
走端点
export FACETMARK_API_KEY=sk-...
export FACETMARK_BASE_URL=https://api.openai.com/v1
export FACETMARK_CHAT_MODEL=gpt-6-luna
export FACETMARK_EMBED_MODEL=text-embedding-3-small
export FACETMARK_EMBED_DIM=1536这是最常见的配置失败,没之一。少了 /v1,每一次调用都 404,包括第一次;而错误是从 provider 那边回来的,看起来像是密钥问题。
不想设环境变量,可以在运行目录放一个 .env。名字一样,前缀一样。
FACETMARK_API_KEY=sk-...
FACETMARK_BASE_URL=https://api.deepseek.com/v1
FACETMARK_CHAT_MODEL=deepseek-flash共享端点或免费端点
备用对话模型按配置顺序尝试。请先确认每个模型在当前账户和端点可用;不要用备用链掩盖错误的接口地址或权限。
export FACETMARK_CHAT_MODEL_FALLBACKS=deepseek-flash,deepseek-v4-proprovider 会记录每一次调用到底是哪个模型答的。任何建立在降级链上的报告,都必须把这个混合比例公开。
本地嵌入,不要 key
本地向量使用 sentence-transformers。首次下载模型后可在服务所在机器计算;网页抓取仍会联网。若保留在线对话配置,摘要与综述仍可能调用该服务。
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 # 或本地模型目录
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 是明确的纯词面路径,一次模型调用都不发。
另见: 模型与配置
建索引#
facetmark index索引按阶段执行,并通过输入指纹复用已完成的工作。新增书签或正文、模型配置变化时,相应阶段需要更新;请查看实际任务输出。
| 阶段 | 干什么 | 要模型吗? |
|---|---|---|
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。
这一跑的代价
费用由文本长度、模型和服务商定价决定,建议先处理小样本。耗时还受网页抓取和站点限流影响;默认单主机并发为 2,并保留请求间隔。
给个量级感:刚才那个真实的 1,700 条书签库,用 --no-fetch 建索引,得到 322 个保存会话、9,132 条边、1,386 个域名、1,775 个向量。
另见: 快速上手
搜索#
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 # 索引规模与覆盖率查询语言#
所有检索入口用的是同一套语法:网页搜索框、facetmark search、/search 与 /quick、MCP 工具、karakeep 插件。它是一门过滤语言,不是第二个排序器——过滤器只决定哪些页面有资格,从不改动幸存页面的分数。完整参考:docs/query-language.md。
facetmark search "postgres domain:github.com -title:tutorial"
facetmark search "kafka added:<7d" # 这周存的
facetmark search 'title:encryption (signal|matrix)'
facetmark search "tag:work sort:date" # 一次浏览,按时间倒序| 字段 | 匹配什么 | 例子 |
|---|---|---|
domain: 别名 site: | 站点,精确或通配 | domain:github.com |
host: | 完整主机名 | host:news.ycombinator.com |
url: | 地址的一部分 | url:*/docs/* |
title: | 只在标题里 | title:encryption |
text: | 抓下来的正文里 | text:"GDPR compliance" |
folder: | 来自哪个浏览器文件夹 | folder:study |
tag: | 你自己的标签,精确匹配 | tag:work |
topic: | 富集写出来的主题 | topic:postgres |
lang: | 检测到的页面语言 | lang:zh |
opened: | 你打开过几次 | opened:10.. |
否定、短语、多选、通配
-facebook 排除一个词
-domain:pinterest.com 排除整个站
"consumer group rebalancing" 精确短语
(security|privacy) 两个词任一
domain:(github.com|gitlab.com) 两个值任一
domain:*.github.io * 是任意长度的一段字符日期
写时长时比的是书签的年龄,所以 added:>90d 是「存了 90 天以上」。写绝对日期时直接比时间戳,所以 added:>=2026-04-01 是「那天及之后存的」。两者方向相反,因为对各自的写法来说,这都是唯一自然的读法。
| 写法 | 含义 |
|---|---|
added:<7d | 最近一周存的 |
added:>90d | 存了 90 天以上 |
added:2026-04 | 那个月存的 |
added:2026-04-01..2026-09-01 | 一个显式区间 |
before:2026-05-01 after:30d | added: 的别名;这两个上的时长按年龄读,所以 after:30d 是最近 30 天 |
排序,以及什么叫一次浏览
sort:date 最新在前,sort:-date 最旧在前;也支持 title、domain、url 和 opened。只有过滤器或排序指令、没有自由文本时,查询直接浏览书签,不调用模型。纯词面搜索和本地向量搜索也不产生在线向量费用。
解析器只在一个 token「不可能是纯文本」时才把它当语法。note: something 不是过滤器,因为 note 不是字段;state-of-the-art 不是三个否定;https://example.com/x 是一个完整的词。而解析不了的值——比如 added:90d,一个没有比较符的时长——会出现在响应的 filters.ignored 里,既不悄悄生效,也不悄悄丢掉。
你自己的标签
Netscape 与 pinboard 导出带着 TAGS 属性,现在它被保留下来:存在书签上、随每条命中返回、并且可以用 tag:work 查询。匹配的是列表里完整的一个元素,所以 tag:work 不会扩到 workshop;要匹配多个用 tag:(work|rust)。POST /bookmark 和 MCP 的 save_bookmark 也接受 tags;重复导入同一个文件时标签取并集而不是覆盖。
起服务:HTTP API 与配对令牌#
facetmark serve # 127.0.0.1:8787除了 /、/health,以及本地页面加载自己用的 /app 和 /app/boot,每条路由都要令牌,在 localhost 上也要 —— 因为你机器上任何一个进程都能访问 127.0.0.1。--host 不是回环地址时,facetmark serve 会告警:这个索引里是你整个浏览兴趣图谱。
令牌
令牌首次运行时生成,保存在数据目录的 pairing-token.txt。请求可使用 Authorization: Bearer <token> 或 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 |
管理接口还检查连接来源,仅允许回环连接,并可通过 FACETMARK_ADMIN_API=false 关闭。远程管理请使用 SSH 转发步骤。
本地页面#
facetmark serve 同时提供 /app 应用和 HTTP API。页面包含搜索、综述、书签库、浏览批次与系统状态,设置从齿轮入口进入。
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)Python 包里的纯 HTML、CSS 和 ES 模块:没有 Node,没有打包器,也就没有会和服务端对不上的构建产物。页面和 API 由同一个进程发出,所以是同源的 —— 这也是它没法托管到别处去的原因:这个服务的 CORS 只对浏览器扩展的来源开放。
五个标签页与管理入口
| 视图 | 地址 | 干什么用 |
|---|---|---|
| 搜索 | /app#/search | 输入确认后先显示不调用模型的词面结果,再更新完整检索结果;中文选字期间暂停查询。用加载更多继续浏览。 |
| 综述 | /app#/ask | 输入问题并提交,根据已存摘要或片段生成带编号引用的回答。沿引用查看来源并核对原文。 |
| 书签库 | /app#/library | 查看收藏活动、正文与向量覆盖、浏览批次和关联规模。搜不到内容时,先确认书签是否导入、正文与索引是否齐全。 |
| 浏览批次 | /app#/sessions | 查看同一时间段保存的书签,进入某个批次继续阅读。 |
| 系统 | /app#/system | 查看服务与模型状态、抓取队列、链接健康和冷层清单。 |
| 设置(齿轮) | /app#/settings | 测试并保存模型配置,调整索引选项,启动或取消索引任务。 |
| 首次设置 | /app#/setup | 按导入书签、选择模型、建立索引三步完成初始化;已有书签时也可再次进入。 |
配对后可以搜索、生成综述和查看书签。导入文件、保存配置与控制索引任务还要求本机连接或 SSH 转发。从搜索结果打开原文会记录阅读活动;搜索和详情页不提供书签编辑、删除操作。
结果行上的标记是什么意思
和扩展弹窗用的是同一套词。在页面里,每个标记鼠标悬停都有一行解释;这张表是为了让你一次看全。
| 标记 | 意思 | 默认 |
|---|---|---|
| 内容相关 | 命中了内容面 —— 已存内容的向量,可能来自正文或标题推断的摘要。 | 开 |
| 提问方式 | 命中了意图面 —— 给这个页面生成的问题的向量。 | 关 |
| 词语 | 命中了字面 · 分词面 —— 对标题、文件夹、网址里完整词的 FTS5。 | 关 |
| 子串 | 命中了字面 · 三元组面 —— 对字符的 FTS5,中文查询和只打了一半的词能命中,靠的就是它。 | 关 |
| 已冷却 | 很久以前存的,一直没打开过,而且有更新的东西看起来把它取代了。排名压低,绝不删除。 | 开 |
| 当时前后一起存的 | 第二组:从上面某条结果出发,在链接图上走一跳。绝不混进排名里。 | 开 |
第二组里每一行都带着走到它的那条边 —— 同一次浏览(同一次上网时存的)、语义相近、已被取代、同一页面、同一站点。这些名字背后的权重在配置表里。
页面怎么拿到令牌
它去问 GET /app/boot。这是唯一一条能把配对令牌交出去的路由,而且只在调用方和请求里写的地址两者都是回环地址时才交。在你自己机器上两条都成立,页面就自己配对好了,没有什么要复制的。
公网上的一个页面可以把某个域名解析到 127.0.0.1,然后让你的浏览器去发这个请求 —— 调用方确实是回环地址。但它改不了 Host 头,那里面还写着攻击者的域名。查这一项,才是拦住一个网站读走你令牌的东西;这也是为什么它是一条单独的路由,而不是挂在现有路由上的一个开关。
在反向代理后面,或者用局域网地址访问时,这个检查会不通过 —— 这是故意的:页面这时给你一个输入框,把 facetmark token 粘一次就行。它存在那个浏览器的本地存储里,不在页面里。
键盘
| 按键 | 作用 |
|---|---|
| / | 未在输入框内打字时,切换到搜索并聚焦查询框。 |
| Enter | 提交查询;有选中建议时采用该建议。中文选字的确认回车不会提交。 |
| ↑ ↓ | 建议列表打开时选择建议;否则在搜索结果之间移动。输入法选字期间保留给输入法。 |
| Esc | 先关闭建议或详情;搜索页没有弹层时清空查询并聚焦搜索框。 |
语言和主题
中英文和主题偏好保存在当前浏览器的当前站点。官网与应用使用同名偏好键,但不同域名的浏览器存储不会自动同步。界面尊重系统的减少动态效果设置。
另见: 应用使用指南
翻页: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 是个参数,而不是实现细节
页面大小决定一次展示多少条,检索深度决定候选池范围。下一页沿用响应中的 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 被置上的原因。两个都在同一个地方、在任何查询开跑之前夹好,所以一个超大的请求不花什么代价,回给你的就是实际给出的那个窗口。
浏览器扩展#
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 加载项目录。
另见: 连接工具与备份
MCP 服务器#
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—— 索引规模与覆盖率。
另见: 连接工具与备份
karakeep 插件#
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' 是保留值,意思是这行桥可以覆写;其他任何值都意味着是真模型写的,桥不碰。
另见: 连接工具与备份
数据库里有什么#
一个 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。完整的故事。
全部配置项#
作为环境变量时,每个名字前面加 FACETMARK_;放在 .env 里也一样。下面的默认值就是出厂值。
存储
| 配置项 | 默认 | 说明 |
|---|---|---|
DATA_DIR | 按系统 | 见安装。 |
DB_NAME | facetmark.db | |
PRIVACY_EXCLUDED_DOMAINS | 空 | 不导入、不抓、不嵌入。 |
模型接入
| 配置项 | 默认 | 说明 |
|---|---|---|
API_KEY | 空 | 空是合法的,代价是失去内容面和意图面。 |
BASE_URL | https://api.openai.com/v1 | 通常以 /v1 结尾;智谱使用 /api/paas/v4。 |
CHAT_MODEL | gpt-6-luna | |
CHAT_EXTRA_BODY | JSON 对象字符串;可设置思考和输出上限。留空不强制采样参数。 | |
EMBED_SEND_DIMENSIONS | false | dimensions |
EMBED_BATCH_SIZE | 64 | 百炼使用 20。 |
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 |
另见: 模型与配置
全部命令#
使用 facetmark COMMAND --help 查看每条命令支持的参数。数据库相关命令通常支持 --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 | 索引规模与覆盖率。 | |
export [FILE] [QUERY] | 把库、或者一条过滤查询选中的那部分,写成 import 能读回来的 JSON。只接受过滤器——排序结果的前几条不是备份。派生数据不写进去,index 会重建。 | --full |
doctor | 诊断这套装置——配置以及每一项设置来自哪里、schema 版本、索引有没有建起来、已存的向量和设置对不对得上。不修任何东西,也不调用模型;每一条发现都写清楚该跑哪条命令。 | --json |
token | 打印扩展要的配对令牌。 | --rotate |
serve | 跑本地 HTTP 服务。 | --host、--port、--mock |
mcp | 在 stdio 上跑 MCP 服务器。 | --mock |
crawl URL | 礼貌地把一个站点走进库里:遵守 robots.txt,隐私排除名单上的主机完全不碰,抓到的每一页都是一条普通书签。 | --max-pages、--off-domain |
update | 报告 PyPI 上有没有更新的 facetmark。只在你运行它的时候查——没有后台检查、没有遥测——而且它自己从不执行升级。 | --json |
demo | 离线造一个合成库并搜它。 | --size、--keep |
config path / config show | `config.toml` 在哪,以及生效的设置和每一项的来源。 | |
eval | 跑检索评测,可以是 A–E 消融。 | --ablation、--rungs、--queries、--bootstrap、--out |
跑你自己的评测
评测命令接受包含 {text, qtype, target_url} 的 JSONL 查询集,在指定书签库上比较检索配置,并输出置信区间与配对检验。先固定数据集和协议,再解释结果。
facetmark eval --no-build \
--queries my-queries.jsonl \
--rungs A,C,full \
--bootstrap 10000 --concurrency 4 \
--out report.json--concurrency > 1 会让 p50 和 p95 失去意义。要质量数字时用它,要延迟就抽一个子集在并发 1 下重跑。
另见: 连接工具与备份
排错#
每次模型调用都返回 404
核对服务商要求的完整 base URL,包括路径前缀(常见为 /v1),并确认模型名称可用。404 可能来自地址或模型名,不能单凭它判断 API Key 无效。
建索引或搜索时报「维度不匹配」
数据库记录的向量维度与 FACETMARK_EMBED_DIM 不一致。先确认模型的实际输出维度,修正配置并重启服务,再重建索引。修改前保留数据库备份。
富化静悄悄地什么也没做
存着的 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 输出贴上来。
另见: 快速上手