我把 14441 条 X 书签从「内容坟场」救了回来:一个完全本地的混合检索 + 语义聚类书签管理器

0 阅读19分钟

​

摘要:我的 X(Twitter)书签攒到了 14441 条,但它基本是个黑洞——原生搜索只做字面匹配,错一个字就零结果;想按主题翻阅,压根没有这个功能。于是我用 Python + SQLite + 本地嵌入模型写了 xbm:一个 100% 本地运行的 X 书签管理器,数据全程不出本机。它提供了五个视图——混合检索、三维分类浏览、UMAP 语义星云图、时间脉络、线索账本——把「存起来就没再打开过」的收藏,变成可检索、可收窄、可视化的知识资产。本文完整拆解它的设计思路、核心实现与踩坑记录。

关键词:SQLite FTS5、混合检索、RRF 融合、EmbeddingGemma、本地嵌入、语义聚类、UMAP、增量同步、PKM、Local-First


引言:14441 条收藏,和一个废掉的搜索框

先说一个真实场景。

我想找回一条三个月前收藏的推文,内容大概是讲 CUDA kernel 优化对大模型推理速度的影响。我打开 X 的书签页,输入「推理优化」——零结果。换成英文「inference optimization」——还是零。

这条推文确实存在,我也确实收藏了。它只是通篇没出现我记忆里的那几个字。

X 原生书签有两个死穴:

  1. 只有字面搜索。它是纯粹的子串/标签匹配,没有任何语义层。你记得的是概念,它匹配的是字符,两者之间有巨大的鸿沟。
  2. 只有倒序时间线。收藏是按时间线性堆砌的,没有类别、没有主题、没有线索。三个月前的收藏 ≈ 永久沉没。

这两个问题叠加的结果是:收藏数量越多,这个功能越不可用。当你的收藏从 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 形成双主线)。

关键不在图表,而在每个节点都写明了「转向了什么、为什么转向」——这是纯数据统计图给不出的东西。

两个工程细节:

  1. 时间口径:优先取「收藏时间」,缺失时回退到推文发布时间。这是必须的——很多人收藏的是几年前的旧推,用发布时间会把历史压扁。
  2. 成本:叙事生成是一次性 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 个月
知识科普283244 个月2012-11 ~ 2026-10381
AI开发工具153438 个月2023-05 ~ 2026-10297
AI变现实操138538 个月2023-09 ~ 2026-10300
开源AI工具116136 个月2021-08 ~ 2026-10151

注意 知识科普 这条:活跃 44 个月,跨越 13 年。从今天回看,这是我唯一经得起时间检验的兴趣——它不在某个热点月份爆发,它是一直在那儿。

每条线索还附带 LLM 生成的「是什么 / 下一步」。比如 AI开发工具 的下一步建议是「工具栈零散,缺乏选型标准和效果评估,建议补一份使用指南」——它直接给出了行动方向,而不是停在统计。

6.3 中断线索:被浪费的前期投入

​编辑

中断线索:零散投入后沉寂的方向

中断线索共 300 条,前几个让我很难堪:

线索条数追踪时长跨度已沉寂
安装 配置8423 个月2023-12 → 2026-064 个月
命令行 切换 可视化5714 个月2024-11 → 2026-046 个月
描述 清晰 具体(Prompt 工程)5214 个月2024-02 → 2026-019 个月
voxdreamer / 3D 生成387 个月2025-06 → 2026-016 个月
本地化应用2817 个月2024-05 → 2026-055 个月

第一条最典型:活跃了 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 书签现在有多少条?

​