摘要:我的 X(Twitter)书签攒到了 14441 条,但它基本是个黑洞——原生搜索只做字面匹配,错一个字就零结果;想按主题翻阅,压根没有这个功能。于是我用 Python + SQLite + 本地嵌入模型写了 xbm:一个 100% 本地运行的 X 书签管理器,数据全程不出本机。它提供了五个视图——混合检索、三维分类浏览、UMAP 语义星云图、时间脉络、线索账本——把「存起来就没再打开过」的收藏,变成可检索、可收窄、可视化的知识资产。本文完整拆解它的设计思路、核心实现与踩坑记录。
关键词:SQLite FTS5、混合检索、RRF 融合、EmbeddingGemma、本地嵌入、语义聚类、UMAP、增量同步、PKM、Local-First
引言:14441 条收藏,和一个废掉的搜索框
先说一个真实场景。
我想找回一条三个月前收藏的推文,内容大概是讲 CUDA kernel 优化对大模型推理速度的影响。我打开 X 的书签页,输入「推理优化」——零结果。换成英文「inference optimization」——还是零。
这条推文确实存在,我也确实收藏了。它只是通篇没出现我记忆里的那几个字。
X 原生书签有两个死穴:
- 只有字面搜索。它是纯粹的子串/标签匹配,没有任何语义层。你记得的是概念,它匹配的是字符,两者之间有巨大的鸿沟。
- 只有倒序时间线。收藏是按时间线性堆砌的,没有类别、没有主题、没有线索。三个月前的收藏 ≈ 永久沉没。
这两个问题叠加的结果是:收藏数量越多,这个功能越不可用。当你的收藏从 100 涨到 10000,它从一个书签栏退化成了一个只进不出的黑洞。
于是我写了 xbm。下面是成品的搜索界面——输入 deepseek,右侧会同时给出字面命中和语义命中的结果,并且每条结果都标明它是靠哪条路径召回的:
编辑
搜索视图:混合检索结果,每条标明字面/语义召回来源
一、整体架构
设计之初我定了三条硬约束:
| 约束 | 含义 | 带来的取舍 |
|---|---|---|
| 数据不出本机 | 无云服务、无 API key、不上传任何书签内容 | 不能用云端向量库 / 云端 LLM 的默认路径 |
| 单文件可搬运 | 全部状态落在一个 SQLite 文件里 | 放弃专用向量数据库,自己在 SQLite 里管理向量 |
| 延迟可接受 | 万级书签下交互需在亚秒级 | 重计算的聚类/投影结果全部物化缓存,只做增量更新 |
架构如下:
图:xbm 数据流架构 —— 同步只读入库,嵌入向量写回 SQLite,五个视图全部基于同一份本地数据;LLM 为可选依赖
技术栈:
- 后端:Python(标准库 + numpy,twscrape 可选)
- 存储:SQLite + FTS5 + 向量列
- 嵌入:EmbeddingGemma(约 160MB),通过
litert_lm进程内推理,可在 CPU 或 WebGPU 上跑 - 可选 LLM:任意 OpenAI 兼容端点,仅用于「LLM 语义聚类」和「线索解读」,不开启也能用一半以上功能
- 前端:Vite
一个关键设计是同步只读。抓取走 twscrape 的增量拉取,只读取自己的书签列表,不点赞、不转发、不发任何写操作。X ToS 的合规性由使用者自己负责,工具层面严格限制为 read-only。
二、视图一:混合检索(FTS5 trigram + EmbeddingGemma + RRF)
这是使用频率最高的入口,也是整套系统里唯一的实时路径。
2.1 为什么不能只用向量?
常见做法是「全量 embedding,然后暴力余弦检索」。但对于书签这种短文本 + 专有名词密集的语料,纯语义检索会犯很蠢的错误:搜 twscrape,返回一堆 scraping 工具,唯独没有 twscrape——因为嵌入模型把专名揉进了「网页抓取」这个语义球里,字面唯一性被稀释了。
所以必须保留一条强字面路。
2.2 关键词路:FTS5 trigram + CJK 子串兜底
SQLite FTS5 的 trigram 分词器天生支持子串匹配,不需要结巴分词,自然解决了中文查询的问题——这是很多人忽略的点。
-- 索引:trigram 分词器,对中日韩无需额外分词
CREATE VIRTUAL TABLE bookmarks_fts USING fts5(
content,
content='bookmarks',
content_rowid='id',
tokenize='trigram'
);
查询时,英文/数字直接走 trigram 子串匹配;中文走同样的路径,无需分词:
def keyword_search(con, q: str, limit: int = 50) -> list[tuple[int, float]]:
"""字面路:trigram 子串匹配,天然支持中文,无需分词。"""
# 转义 FTS5 特殊字符,避免查询语法注入
safe = q.replace('"', '""')
rows = con.execute("""
SELECT rowid, bm25(bookmarks_fts) AS score
FROM bookmarks_fts
WHERE bookmarks_fts MATCH ?
ORDER BY score
LIMIT ?
""", (f'"{safe}"', limit)).fetchall()
# bm25 越小越相关,转成越大越好
return [(r[0], 1.0 / (1.0 + max(0.0, -r[1]))) for r in rows]
踩坑点:一定要转义双引号。用户输入里带 " 会直接让查询抛语法错误,这是上线前必补的防御。
2.3 语义路:本地 EmbeddingGemma
嵌入模型跑在本地有两个硬性收益:数据不出本机、零 API 成本(书签天天在涨,按调用付费是长期负担)。
实测吞吐:
| 后端 | 吞吐 | 14441 条全量嵌入耗时 |
|---|---|---|
| CPU | 约 9 条/秒 | 约 27 分钟 |
| WebGPU | 约 15–16 条/秒 | 约 15 分钟 |
首次全量确实要等,但之后是纯增量——新书签进来跑几条就行。
2.4 两路融合:RRF
我没有用加权分数融合(需要两个分布的归一化scale,调起来很脆),而是用 Reciprocal Rank Fusion(RRF) ——只依赖排名,天然免疫两路分数量纲不一致的问题:
def rrf_fuse(*ranked_lists: list[int], k: int = 60, top_n: int = 30) -> list[int]:
"""多路结果融合。ranked_lists 每项为按相关度降序的 id 列表。"""
scores: dict[int, float] = {}
for ranked in ranked_lists:
for rank, doc_id in enumerate(ranked, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return [i for i, _ in sorted(scores.items(), key=lambda x: -x[1])[:top_n]]
k=60 是原论文的经验值,它对 top 排名的权重压制得比较温和,实测比 k=20 更稳。
前端会把每条结果的召回路径标出来(字面命中 🟦 / 语义命中 🟪 / 双路命中),这个小小的 UI 细节非常重要:它可以让用户理解为什么这条会出现,而不是觉得系统在玄学推荐。
三、视图二:三维分类浏览——为什么单标签不够用
这是我最想讲清楚的一个设计。
3.1 单标签的困境
大多数书签工具的做法是:给每条内容打一个类别标签。问题是,你的收藏从来不是单一属性的。「一条讲如何用本地模型跑 RAG 的推文」,它既是 AI/LLM,也是工程实践,还是本地部署方案——任何一个单标签都在丢失信息。
更糟的是,当你有 14441 条内容、每个类别塞进几千条时,那个标签本身失去了筛选能力。
3.2 三个正交维度
xbm 用了三条互相独立的轴:
| 维度 | 生成方式 | 规模(14441 条实测) | 回答的问题 |
|---|---|---|---|
| 类目 | 零样本语义分类 | 12 类 | 「它在讲哪个大领域」 |
| 关键词聚类 | IDF 加权词向量 → 引领聚类(leader clustering) | 1115 簇 | 「它在用什么词」 |
| LLM 语义聚类 | LLM 归纳簇名 | 27 簇 | 「它在表达什么意图」 |
三条轴各自的完整视图(下方标签云即为全部结果,可按规模切换显示前 N 项):
编辑
分类浏览:类目 / 关键词聚类 / LLM 聚类三轴标签云
实测分布里,类目以 AI与LLM(4481 条)为绝对主力,其后是 商业与投资(1882)、设计与创作(1664)、工具与效率(1304);而 LLM 语义簇给出的命名明显更「意图化」——知识科普(2032)、AI开发工具(1534)、AI变现实操(1385)、本地大模型推理加速(862)、AI视频生成(667)。这两组标签看同一批数据,得出的结论完全不同,这正是三轴并存的价值。
3.3 交集= 真正的收窄
三轴可以任意组合。取交集的含义是:语义相近,且用词相同。
举个实测例子:从 14441 条出发,依次叠加 AI与LLM(类目)+ 安装 配置(关键词簇)+ 本地部署(LLM 簇),最终剩下 61 条。
从 14441 → 61,每一步收窄都有明确依据,没有任何黑箱排序。
下图是小样本下的筛选过程,可以看到每一步过滤后的剩余卡片:
编辑
分类浏览:交集筛选后剩余 16 条的过程
3.4 关键词聚类:IDF + 引领聚类的实现
1115 个关键词簇不是靠 K-Means(你没法预先告诉它 K),而是引领聚类(leader clustering) ——一个 O(n) 的贪心算法,天然适合流式增量:
import numpy as np
from collections import Counter
def build_idf(con, docs: list[str]) -> dict[str, float]:
"""词表 IDF:把高频停用词(the / 的 / RT)的权重压下去。"""
N = len(docs)
df: Counter = Counter()
for d in docs:
for w in set(tokenize(d)): # tokenize: 中英混合切分
df[w] += 1
return {w: np.log(N / (1 + c)) for w, c in df.items()}
def leader_cluster(vectors: np.ndarray, doc_ids: list[int],
threshold: float = 0.62) -> dict[int, list[int]]:
"""引领聚类:每个样本与已有 leader 比相似度,过阈值就入簇,否则自成一簇。
复杂度 O(n·k),k 为簇数,远快于 O(n²) 的全对比。"""
clusters: dict[int, list[int]] = {} # leader_idx -> members
leaders: list[np.ndarray] = []
leader_ids: list[int] = []
for idx, vec in enumerate(vectors):
if leaders:
sims = np.dot(np.stack(leaders), vec) # 向量已 L2 归一化
best = int(np.argmax(sims))
if sims[best] >= threshold:
clusters[leader_ids[best]].append(doc_ids[idx])
continue
leader_ids.append(doc_ids[idx])
leaders.append(vec)
clusters[doc_ids[idx]] = [doc_ids[idx]]
return clusters
把 IDF 加权后的词向量喂进去,阈值 0.62 是我实测下来粒度最舒服的一档——再高会产生大量单元素簇,再低会把不相关话题糊成一团。
关于 OpenAI 兼容 端点:LLM 语义簇的命名和质量明显好于纯统计聚类,但它需要挂一个本地 LLM(http://127.0.0.1:8080/v1 这种 llama.cpp server)。我把它做成可选:不配置也能用类目 + 关键词簇两轴,配了才有第三轴。工具不会因为缺环境而残废。
四、视图三:UMAP 语义星云图
列表视图有一个根本缺陷:它永远展示不了「你的兴趣的形状」。
把 14386 条书签的 embedding 用 UMAP 投影到二维之后,会看到三类结构:
| 结构 | 含义 |
|---|---|
| 星团 | 你真实投入关注的方向,星团越大,投入越强 |
| 孤立点 | 算法从未推荐给你、但独树一帜的长尾内容 |
| 密集重叠区 | 你在同一话题上反复收藏(意味着大量冗余) |
孤立点尤其有价值。推荐算法的工作方式是把你已经喜欢的同类内容反复喂给你,所以你的收藏结构天然是被算法塑形过的。那些落在空白区、离群的点,恰恰是你主动越过算法边界捞回来的东西——它们通常是最值得重看的。
投影是纯离线的批处理任务:算完之后直接把 (x, y) 坐标写回 SQLite,前端只负责画散点,交互零延迟。
def project():
ids, vecs = load_all_embeddings() # 14386 x 768
reducer = umap.UMAP(n_neighbors=15, min_dist=0.1,
metric='cosine', random_state=42)
xy = reducer.fit_transform(vecs)
save_coords(ids, xy) # 物化,避免每次重算
📎 这一视图的截图在仓库 README 里(星云图是整个项目视觉上最抓人的一张)。
五、视图四:时间脉络
如果 UMAP 回答的是「我的兴趣是什么形状」,时间脉络回答的是「它是怎么变成现在这样的」。
做法分两步:先按月聚合主题占比,再把统计结果交给 LLM 组织成叙事。
编辑
时间脉络:按月聚合的主题演化叙事与转折点
在我的数据上,它还原出了这样一条轨迹:
2012-11:
知识科普主线确立 → 2020 前后:转向海外生活→ 2021-08:开源AI工具出现 → 2023-05:AI开发工具→ 2023-09:AI变现实操→ 2024-09:开源 AI 工具占比反超知识科普,成为第二大关注。
以及逐月的增量描述(2024-01 科普占比明显回升,AI 阅读/收藏回落、2024-05 AI 工具回归并成为新主线、2024-06 形成双主线)。
关键不在图表,而在每个节点都写明了「转向了什么、为什么转向」——这是纯数据统计图给不出的东西。
两个工程细节:
- 时间口径:优先取「收藏时间」,缺失时回退到推文发布时间。这是必须的——很多人收藏的是几年前的旧推,用发布时间会把历史压扁。
- 成本:叙事生成是一次性 LLM 调用,结果物化。这点很重要,不要让用户每次点开视图都烧一次 token。
六、视图五:线索账本(我最喜欢的功能)
这是整个项目里我认为最有原创性的部分。
6.1 它要解决什么问题?
按月统计能告诉你「某话题在某个月有多少条」,但这是算法的镜子——某话题在某月爆发,可能只是因为那天 X 首页全在推它,而不是因为你真正在投入。
线索账本要做的是:把「算法热点」和「你的知识资产」分开。
它对每条语义线索做生命周期分析,然后归入四种状态:
| 状态 | 判定规则 | 含义 |
|---|---|---|
| ✓ 持续 | 活跃月数 ≥ 12 且 近 3 个月仍有收藏 | 你的知识体系骨架,长期稳定投入 |
| ⚠ 中断 | 近 N 个月无新增(N ≥ 3),但历史跨度长 | 你投入过、算法后来不推了,你也跟着停了 |
| 🌱 新芽 | 首次出现 ≤ 3 个月 | 刚冒头的方向,值得观察 |
| ⚡ 热点脉冲 | 短期密集后快速回落 | 纯算法热点,不是你的兴趣 |
核心的一段判定逻辑:
from datetime import date
def classify_thread(monthly: dict[str, int], today: date,
active_min: int = 12,
dormant_months: int = 3) -> str:
"""monthly: {'2023-05': 12, ...} 按月收藏条数。"""
if not monthly:
return 'empty'
active = [m for m, c in monthly.items() if c > 0]
first, last = min(active), max(active)
months_since = month_diff(last, today)
# 历史跨度(月)
span = month_diff(first, last) + 1
density = len(active) / span
if months_since >= dormant_months and len(active) >= 6:
return 'interrupted' # 中断:投入过,后来断了
if len(active) >= active_min and density >= 0.4:
return 'persistent' # 持续:长期且稳定
if span <= 3:
return 'new' # 新芽
return 'pulse' # 热点脉冲
6.2 持续线索:你的知识骨架
编辑
持续线索:长期反复出现的系统性兴趣
实测判定的持续线索共 776 条(簇),排在最前面的四个:
| 线索 | 条数 | 活跃时长 | 跨度 | 近 3 个月 |
|---|---|---|---|---|
| 知识科普 | 2832 | 44 个月 | 2012-11 ~ 2026-10 | 381 |
| AI开发工具 | 1534 | 38 个月 | 2023-05 ~ 2026-10 | 297 |
| AI变现实操 | 1385 | 38 个月 | 2023-09 ~ 2026-10 | 300 |
| 开源AI工具 | 1161 | 36 个月 | 2021-08 ~ 2026-10 | 151 |
注意 知识科普 这条:活跃 44 个月,跨越 13 年。从今天回看,这是我唯一经得起时间检验的兴趣——它不在某个热点月份爆发,它是一直在那儿。
每条线索还附带 LLM 生成的「是什么 / 下一步」。比如 AI开发工具 的下一步建议是「工具栈零散,缺乏选型标准和效果评估,建议补一份使用指南」——它直接给出了行动方向,而不是停在统计。
6.3 中断线索:被浪费的前期投入
编辑
中断线索:零散投入后沉寂的方向
中断线索共 300 条,前几个让我很难堪:
| 线索 | 条数 | 追踪时长 | 跨度 | 已沉寂 |
|---|---|---|---|---|
| 安装 配置 | 84 | 23 个月 | 2023-12 → 2026-06 | 4 个月 |
| 命令行 切换 可视化 | 57 | 14 个月 | 2024-11 → 2026-04 | 6 个月 |
| 描述 清晰 具体(Prompt 工程) | 52 | 14 个月 | 2024-02 → 2026-01 | 9 个月 |
| voxdreamer / 3D 生成 | 38 | 7 个月 | 2025-06 → 2026-01 | 6 个月 |
| 本地化应用 | 28 | 17 个月 | 2024-05 → 2026-05 | 5 个月 |
第一条最典型:活跃了 23 个月,然后沉寂 4 个月。
这不是我不感兴趣了——是推荐算法不推了,而我没有任何机制把我自己过去的投入重新推到我面前。X 的信息流是「当下导向」的,它永远不会提醒你:嘿,你两年前在这个方向上攒了 84 条素材。
线索账本回答的正是这个问题:哪些值得捡起来。
七、工程实现要点
7.1 增量同步
全量重抓是不现实的(慢、易被限流、浪费 API)。twscrape 支持增量拉取:
async def incremental_sync(con):
"""增量同步:拉到已存在的最早书签即停。"""
api = API(pool_path)
since_id = con.execute(
"SELECT MAX(tweet_id) FROM bookmarks").fetchone()[0]
new_items = []
async for tweet in api.bookmarks(limit=None):
if tweet.id <= since_id:
break # 回到已知范围,提前退出
new_items.append(tweet)
bulk_insert(con, new_items) # 事务批量写
7.2 存储布局
所有状态在一个 .sqlite 文件里:
-- 主表
CREATE TABLE bookmarks (
id INTEGER PRIMARY KEY,
tweet_id INTEGER UNIQUE,
author TEXT,
content TEXT,
created_at TEXT, -- 推文发布时间
bookmarked_at TEXT, -- 收藏时间(口径优先)
has_media INTEGER DEFAULT 0
);
-- 字面路:FTS5 trigram 虚表
CREATE VIRTUAL TABLE bookmarks_fts USING fts5(content, content='bookmarks',
content_rowid='id', tokenize='trigram');
-- 语义路:768 维向量,blob 存储
ALTER TABLE bookmarks ADD COLUMN emb BLOB;
-- 向量和聚类/投影结果同样要么是列、要么是独立物化表
CREATE TABLE cluster_assign (bookmark_id INTEGER, axis TEXT, cluster_key TEXT);
CREATE TABLE umap_coords (bookmark_id INTEGER PRIMARY KEY, x REAL, y REAL);
7.3 新书签自动归位
新同步进来的书签会当场计算 embedding,然后用现有 leader 做一次相似度比对完成归类——这意味着零次全量重聚类。全量重聚类只在用户手动点击「重新聚类」时发生。
7.4 实测规模与性能
| 指标 | 实测值 |
|---|---|
| 书签总量 | 14,441 条 |
| 参与 UMAP 投影 | 14,386 条 |
| 嵌入(首次全量,CPU) | 约 27 分钟(~9 条/s) |
| 嵌入(首次全量,WebGPU) | 约 15 分钟(~15–16 条/s) |
| 单条搜索响应 | 亚秒级(指数主导) |
| 三条分类轴 | 12 类 / 1115 关键词簇 / 27 LLM 簇 |
| 线索账本 | 持续 776 + 中断 300 |
八、踩坑记录(这部分建议读两遍)
1. Gemma 嵌入的余弦基线偏高,这是最大的坑。 大多数嵌入模型里,两段完全无关的文本余弦相似度在 0.0–0.2 附近,所以「>0.7 算相似」这类经验阈值能直接用。但 EmbeddingGemma 的基线在 0.5–0.6——两段毫无关系的文本,余弦也能到 0.5。
这直接废掉了所有网上抄来的阈值。我的处理办法不是硬调数字,而是先用本地语料采样一批人工判定为「明显不相关」的文本对,测出实际基线分布,再按分位数定阈值(本文代码里的 0.62 就是这样来的)。如果你换模型,这一步必须重做。
2. trigram 索引会让数据库明显变大。 tokenize='trigram' 为每个三字符窗口建索引,中英混合文本下索引体积通常超过原表。这是拿空间换「无需分词」的便利,我认为值,但你需要提前知道:14k 条书签大概会让 db 文件翻一倍。
3. FTS5 查询务必转义。 前面说过,再强调一次:用户输入直接拼进 MATCH 就是语法注入,一个引号就能崩给你看。
4. 不要在每次打开视图时调 LLM。 时间脉络的叙事、线索解读,都是 LLM 产物,必须物化。否则你会得到一个又慢又烧钱的应用。
5. UMAP 的结果要固定随机种子。 random_state=42 不是仪式感。否则每次重新投影坐标全变,用户标注的心智图会失效。
6. WebGPU 需要较新的 Chrome / Edge。 而且首次加载模型有冷启动成本。给前端留一个明确的降级提示,比让用户对着卡住的进度条猜要好。
九、已知限制与路线图
我不打算把它包装成没有缺陷的东西,以下是真实的边界:
| 限制 | 说明 | 计划 |
|---|---|---|
| 图片内容未被索引 | 当前只对文本做嵌入,书签里的图片 Alt/OCR 信息丢失 | 接入 OCR |
| 外链正文不抓取 | 只索引推文本身,链接文章正文不入库 | 可选抓取开关 |
| WebGPU 依赖 | 需要较新浏览器版本 | 保留 CPU 降级路径 |
| X ToS 合规 | 工具严格只读,但合规性由使用者自负 | — |
十、结语
回到最开始的问题。
我把 14441 条书签从「黑洞」里救出来的过程,本质上不是加了个搜索框,而是给一堆无序数据重建了结构:
- 混合检索让它能被找到;
- 三维分类让它能被收窄到可操作的量级;
- UMAP 星云图让它第一次有了形状;
- 时间脉络解释了它为什么长成这样;
- 线索账本把算法热点和真正的知识资产分开,告诉你哪些值得捡起来。
最后一张我最得意——它回答的是一个统计数据永远回答不了的问题:我投入过的东西,后来去哪了?
项目已开源,欢迎 Star 和提 Issue:👉github.com/fudingyu/xb…
评论区聊聊:你的 X 书签现在有多少条?