积累了足够多的卡片之后,下一个障碍不是数量,而是孤立。每张卡片在自己那本书的语境里成立,但卡片之间没有对话。这篇讲的是如何让来自不同书的卡片碰撞——找到它们共同指向的隐藏主题,标注它们之间的关系类型,让不同框架的摩擦产生新的东西。
这是「从阅读到知识」系列的第三篇。第一篇结尾留了三个问题,第二篇回答了第一个,又引出了三个新的。这一篇收束其中两个:
这两个问题的根在同一个地方:卡片只有在碰撞中才能产生新的东西。单本书的卡片在同一个框架下互相支撑,真正有意思的洞见往往出现在不同框架的摩擦处。
解决方案是一个叫「主题簇」(Cluster)的概念:把来自不同书、指向同一隐藏主题的卡片归在一起,并标注它们两两之间的关系类型。
卡片系统有一个隐藏的结构问题。把划线变成卡片,解决了「上下文依赖」的问题——卡片可以独立存在,不需要回头翻书。但卡片库的组织结构依然是按书来的。每张卡片属于某本书,浏览时也是在一本书的范围里看。
单本书的卡片之间固然有联系,但这些联系是作者已经梳理好的。通常,同一本书的观点在同一个框架下,它们天然互相支撑。真正有意思的东西往往不在这里,而在不同框架之间:一本讲系统思维的书说「决策发生在边界」,一本讲心理学的书说「认知负荷在转折点达到峰值」,将它们放在一起,两个命题忽然有了一个共同的含义,而这个含义在任何一本书里都没有明确写出来。
但这种碰撞不会自然发生。书的物理分隔阻断了跨书联系,卡片库的组织方式复制了这种分隔。要让不同书的卡片碰在一起,需要系统主动去构造这种碰撞。
「连接」这个词很容易被稀释。如果只是说两张卡片「主题相关」,那和关键词搜索没有本质区别。搜索「决策」,两张卡片都出来了,但它们之间的关系是什么,还是不清楚。
有价值的连接必须能产生一个「第三个东西」:一个在任何一张卡片里都没有直接写出来的新判断。如果把两张卡片放在一起,你只是说「对,它们都在讲决策」,这不是连接,只是归类。
具体来说,有价值的关系有四种:
连接的基本单元不是「两张卡片的对」,而是主题簇:一组来自不同书的卡片共同指向的隐藏主题。在一个簇里,从中选出 2-4 个最有价值的两两关系,标注类型,并写一句说明为什么这两张放在一起有意思。
让卡片碰撞的前提,是能找到语义相似的卡片。关键词匹配做不到这件事——同一个主题在不同领域里用的词汇完全不同,不能指望它们有公共关键词。
向量相似度可以。把每张卡片的「哲思」向量化(Chen 注:向量化(embedding)是把一段文本转换成一组数字(向量)的过程。模型把语义相近的文本映射到空间里相邻的位置——「苹果」和「水果」的向量比「苹果」和「螺丝刀」的向量距离近得多。这个距离就是所谓的「相似度」,通常用余弦相似度衡量,取值 0–1,越接近 1 表示越相似。),相似度高的卡片在向量空间里距离近,不管它们用的是什么词。
向量存储用的是 Upstash Vector,使用内置的 TEXT_EMBEDDING_3_SMALL 模型(Dense Index),不需要调用外部 embedding API。
嵌入文本的格式是「哲思」加上标签:
export function cardEmbedText(content: string, tags: string[]): string {
return tags.length > 0 ? `${content} #${tags.join(" #")}` : content;
}标签加进去不是为了让相同标签的卡片必然靠近,而是让语义场更精准——「认知负荷 #心理学 #决策」和「认知负荷 #产品设计」在向量空间里的方向会略有差异,有助于把跨领域的相似性和同领域的相似性区分开来。
有了每张卡片的 top-20 邻居(相似度 > 0.82(Chen 注:0.82 是经过实验调整的值。太低(如 0.75)会把同领域的普通相关卡片也归在一起,簇的主题变得模糊;太高(如 0.90)则几乎找不到跨书的匹配,因为不同作者的表达风格本身就会拉开相似度。0.82 在「足够相似」和「跨书可达」之间取得平衡。),且跨书),聚类用 BFS 连通分量(Chen 注:BFS(广度优先搜索)连通分量:把卡片看作图的节点,相似度达标的两张卡片之间连一条边。从任意未访问节点出发,BFS 会沿着边扩展,把所有相互可达的节点收入同一组——这一组就是一个「连通分量」,也就是一个候选簇。与 k-means 等算法不同,这个方法不需要预设有多少个簇,结构完全由数据的相似度图决定。):
function buildClusters(cardSlugs, neighbors, bookOf) {
const visited = new Set();
const clusters = [];
for (const slug of cardSlugs) {
if (visited.has(slug)) continue;
// BFS 扩展连通分量
const group = [];
const queue =
不用 k-means(Chen 注:k-means 是一种经典聚类算法:随机初始化 k 个中心点,反复把每个数据点分配给最近的中心,再重新计算中心位置,直到收敛。问题在于 k 必须提前给定——你要先告诉算法「分成几类」,但主题空间没有固定答案。) 或其他需要预设簇数的算法,原因是主题空间是动态的——随着卡片增加,新的簇自然涌现,旧的簇可能扩大或消失。BFS 连通分量让结构从数据里自然生长,不需要提前知道有多少个主题。
50% 同书过滤是关键的一步。没有这个过滤,「某本系统思维书里的 5 张卡片」也会形成一个连通分量。它们语义相似没错,但这种相似性是单本书的内部结构造成的,没有跨书碰撞,不是我们想要的。
对聚类结果进行批量处理,每批 10 个簇,给出全部卡片的哲思内容,生成主题名(4-10 字)、跨书洞见(30-60 字)、2-4 个最有价值的两两关系(类型 + 说明)。用 tool_use 约束输出格式:
await client.messages.create({
model: "claude-haiku-4-5",
tools: [{
name: "save_clusters",
input_schema: {
// clusters[]: { index, theme, synthesis, edges[] }
// edges[]: { cardA, cardB, type: "类比"|"互证"|"矛盾"|"延伸", note }
},
}],
tool_choice: { type: "tool", name: "save_clusters" },
messages: [{ role: "user", content: `...` }],
});全量覆盖而不是增量更新,是因为簇结构取决于全局相似度图,新卡片加入会改变整个图的连通性。增量更新逻辑复杂,很难保证一致性,而全量覆盖简单且正确。
QStash 的 30 分钟延迟是给 per-book 的工作任务留足时间落库。簇重建要用到所有卡片的向量,如果在任务结束前就重建,新卡片还没来得及入库,这一轮的连接会漏掉。
在「阅读」页面的卡片 tab 里,「今日连接」在「今日卡片」之后、「每日发现」之前。每天从所有簇里按日期种子固定选一个展示。选择逻辑和第二篇的「今日卡片」一样,用日期字符串做哈希 seed,对所有簇 ID 打分,取得分最小的那个。同一天刷新多少次都是同一个簇,第二天自动换。
星座图(ClusterConstellation)的几个设计取舍值得记录:
只渲染参与 Claude 边的节点。一个簇可以有 3-8 张卡片,但 Claude 只标注了 2-4 对关系,没有出现在任何关系里的卡片单独列在「其他相关卡片」区块,不进图。星座图的核心是「关系」而不是「成员」,把所有节点都画出来但大部分没有连线,图会显得稀疏而混乱。
边的颜色区分关系类型,矛盾用虚线。视觉编码让用户在看到图的第一眼就能判断这是一个「有争议」的簇还是「相互印证」的簇,不需要先点进去才知道。
点击连线,右侧从「涉及书目」切换为「边详情」:两张卡片的原文 + 哲思 + 关系说明。星座图是入口,卡片内容才是目的地。
节点大小自适应:固定 viewBox(380×320),用节点数反推环半径和节点半径,保证不重叠、不溢出。节点少时封面更大,视觉重心在内容;节点多时封面缩小,视觉重心在结构。
把这一篇和前两篇放在一起,遗留问题的收束情况:
还没有答案的:
这两个问题需要一个更高层的视角:不是看「今天的连接是什么」,而是看「所有连接加起来,形成了一张什么样的地图,地图上哪里还是空的」。这是第四篇的主题。