赛博仓鼠如何对抗碎片化阅读

503 阅读8分钟

写在前面

本文作者是一个赛博仓鼠,囤积文章如山,打开率却堪比银行金库。 如题,赛博仓鼠是如何努力对抗碎片化阅读的呢? 先来看看几张成品:

从 Obsidian 收集高亮片段:

Obsidian示例1

在 Chrome 浏览文章时发现类似的片段之后:

Chrome示例1

如果对某些结论感到兴趣,让 AI 结合此前收藏的若干线索进行解读:

Chrome示例2

上述呈现为我的场景定制,它能为我完成以下操作:

  1. 从 Obsidian 笔记软件收集知识片段
  2. 从 Chrome 浏览器收集知识片段
  3. 在 Chrome 展现历史关联并生成AI解读

第一二点是常见的笔记场景(Obsidian)和碎片化阅读场景(Chrome)。 第三点目标是「温故知新」:借助AI分析当前文本和历史关联片段,例如差异对比、字面逻辑和表达张力分析等。

本文的完整代码,我放在daijinru/wenko,作为爱好会长期更新。欢迎指正。 作为小白,做了自认为最容易入门的技术选型,如图:

流程图

准备工作

开发AI应用的最佳工具是什么呢?当然是AI应用本身。首先我们准备几个强力工具:

  1. 集成开发环境 VSCode + Copilot
  2. 作为技术顾问的纳米AI桌面端
  3. 负责 Text Embedding 的本地模型 nomic-embed-text
  4. 负责 AI 解读的免费模型 qwen3-32b:free
  5. 用于存储向量和文本数据的 MongoDB Atlas

此外,本文不作完整步骤复现,而是着重于几个关键过程与其技术复现:

  1. 在有限的硬件条件下运行轻量化、成本低的技术方案
  2. 如何快速开发各类工具以满足多种知识采集场景
  3. 简单的讨论知识碎片化

开始动手

动手环节是:

  1. 运行环境
  2. 文本向量化、存储和近似搜索
  3. 开发知识采集插件
  4. 文本解读生成

如果我们有基础,折腾起来应该不费劲。在动手过程,会存在些许技术选型的问题:我倾向于将本项目看作一个MVP(最小化可行产品),所以尽可能以快速完成目标为准。例如数据存储为何不选择 FAISS 或者 Elasticsearch 等更具技术性的方案——仅因为选用 MongoDB Atlas 后仅需数十行易于理解的代码,对小白较为友好。

运行环境

代码运行环境准备如下:

  1. Node.js 作为 Obsidian Plugin 和 Chrome Plugin 的运行环境
  2. Go 用于开发数据存取服务
  3. 运行 nomic-embed-textOllama
  4. 注册 MongoDB Atlas 服务,创建免费方案。
  5. 注册 OpenRouter,拿到 API Key

上述准备工作完成后获得的配置用于填入到(如果没有请新建)位于项目根目录的 config.json。 运行环境准备妥当后,我们来到第二步。

文本向量化、存储和近似搜索

文本向量化指将文本片段转化为高维向量(嵌入表示),在本文通过本地模型 nomic-embed-text 完成。 上述模型通过 ollama 快速部署:

$ ollama pull nomic-embed-text

由于 nomic-embed-text 模型仅用于生成文本嵌入向量,无需 ollama run 命令,可直接调用:

$ curl http://localhost:11434/api/embeddings -d '{ "model": "nomic-embed-text", "prompt": "Your text here" }'

使用 go 代码编写,例如:

type OllamaResponse struct {
	Embedding []float32 `json:"embedding"`
}
// 省略其它逻辑
// text 变量是我们提交的文本片段
resp, err := http.Post(config.OllamaURL, "application/json",
 bytes.NewBufferString(fmt.Sprintf(`{"model":"nomic-embed-text","prompt":"%s"}`, text)))
if err != nil {
	return "", err
}
defer resp.Body.Close()
var response OllamaResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
	return "", err
}

这里需留意 nomic-embed-text 生成的是768维向量,关系后续近似搜索的配置。 接下来,我们着手存储工作。首先定义一个简单的存储结构体 EmbeddingDoc

type EmbeddingDoc struct {
	ID string `bson:"_id,omitempty"`
	Content string `bson:"content"`
	Article string `bson:"article"`
	Vector []float32 `bson:"vector"`
	CreatedAt time.Time `bson:"created_at"`
}

在该结构体,Content 用于获取原文,Vector 用于建立索引,以便完成近似搜索。

建立索引也是关键的一个步骤,请按照 Atlas Search 提供的 Create Search Index 流程创建。

前面已完成在线数据库的配置,这里是存储过程代码:

// 省略连接数据库的操作
collection := client.Database(config.DatabaseName).Collection(config.Collection)
doc := EmbeddingDoc{
	Content:   text,
	Vector:    response.Embedding,
	CreatedAt: time.Now(),
}
result, err := collection.InsertOne(context.TODO(), doc)

如果我们顺利的完成上述几个步骤,并积累了一定量的文本片段,接下来可以跑一遍近似搜索。 不要忘了先前提到的建立索引。

create-vector-index

这里是近似搜索的简单实现:

// 省略数据库连接操作
// 向量近似搜索,提前在 Atlas Search 创建索引 numCandidates 768 默认 cosine
pipeline := mongo.Pipeline{
	{{
		Key: "$vectorSearch",
		Value: bson.D{
			{Key: "queryVector", Value: queryVector},
			{Key: "path", Value: "vector"},
			{Key: "numCandidates", Value: 768},
			{Key: "limit", Value: 5},
			{Key: "index", Value: "vector_index"},
		},
	}},
}
// 省略解码查找结果数据的过程

完成上面的内容,我们得到一个可以完成文本向量化、存储和近似搜索的服务。可以通过命令行简单的测试:

$ curl -X POST http://localhost:8080/generate -H "Content-Type: application/json" -d '{"text": "Hello World"}'
$ curl -X POST http://localhost:8080/generate -H "Content-Type: application/json" -d '{"text": "Hi World"}'
$ curl -X POST http://localhost:8080/search -H "Content-Type: application/json" -d '{"text": "world"}'

开发知识采集插件

接下来的内容比上一章节要更加扣题,但是开发内容相对常规:主要是文本的收集、近似搜索结果和解读开发交互界面。按照个人的习惯,我选择为 Obsidian 和 Chrome 两个软件开发插件。 首先为 Obsidian 开发一个用于收集==高亮文本==的插件。

Obsidian 插件

Obsidian 是我最常用的笔记软件,是因为它有丰富且开放的插件生态(还有更多的优点,这里不展开)。 为它开发插件,首先要 Fork 一份 obsidian-sample-plugin 到本地。 这里也可直接使用本文提到的代码仓库路径。在该插件的基础上开发,我们仅需将该路径(即 inbox/obsidian/wenku) 作为本地目录打开。这里推荐阅读插件开发指南

该插件的功能比较简单,仅仅用于收集当前页面的高亮文本,代码示例:

this.registerEvent( 
  this.app.workspace.on("file-menu",  (menu, file) => {
	menu.addItem((item)  => {
	  item 
		.setTitle("🍉 对高亮 Embedding") // 注册一个按钮到页面菜单
		.setIcon("highlighter")
		.onClick(async () => {
		  const highlights = this.collectHighlights()
		  if (highlights.length === 0) {
			new Notice("没有找到高亮内容");
			return;
		  }
		  new Notice(`已收集高亮内容: ${highlights.join(", ")}`);

拿到高亮文本数组,请求此前开发的服务接口 /generate 转换向量并存储:

private async generateVector(text: string): Promise<string> {
	try {
	  const response = await request({
		url: 'http://localhost:8080/generate',
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({  text })
	  });
	
	  const result: GenerateResult = JSON.parse(response); 
	  if (!result.id)  throw new Error("无效的ID响应");
	
	  console.info(`生成的ID: ${result.id}`);

这里需要考虑:并非所有高亮文本皆需转换为向量。因此在文本向量嵌入前需要作重复/相似度计算,但在本文的实现相对小白(原型验证优先):先做近似搜索,将原文和返回结果中作一番简单的比较后再决定是否将其存入。

Chrome 插件

由于经常通过 Chrome 阅读网络文章。我设想的场景是:当阅读到某个段落,会想到此前深入理解过的某个概念,或者仅仅是想把这段文字和另外一篇文章的观点结合起来。如果有一个工具能够自动创建这种连接,应该会很有帮助。所以本文的结论是开发一个 Chrome 插件(扩展),负责收集用户选定的文本,调用语义(近似)搜索服务得到关联的文档集合。 初始化 Chrome 插件应用,同样可选取本项目(路径在 inbox/chrome/chrome-wenko)或使用工具:

$ npm create chrome-ext@latest chrome-wenko -- --template react-ts

开发 Chrome 插件可能会陷入多种交互界面的选择困难。我选择的是 Sidepanel 侧边栏组件。它的优点是非侵入式,既不干扰原文阅读,同时有独立呈现空间。但是由于其架构特性,Sidepanel 需要通过 chrome.runtime 提供的 onMessagesendMessage 和原文页面作交互,譬如: 先往页面菜单注册一个「高亮」按钮,用于发送选中的文本:

chrome.contextMenus.create({ 
  id: "highlightAndOpenPanel",
  title: "高亮选中文本并打开Wenko",  // 菜单显示名称 
  contexts: ["selection"]  // 仅在用户选中文本时显示 
})

当按下右键菜单的「高亮」按钮,需要发送事件:

// 先打开 Sidepanel 界面
hrome.sidePanel.open({ windowId: tab.windowId })

// 等一会儿将选中文本发送到 sidepanel
chrome.runtime.sendMessage({ 
  action: "updateSidePanel",
  text: selectedText
})

在 Sidepanel 组件,监听 updateSidePanel 事件,将接收内容呈现:

const [selectedText, setSelectedText] = useState('')
chrome.runtime.onMessage.addListener((request)  => {
  if (request.action  === "updateSidePanel") {
	const text = request.text
	setSelectedText(text)
// 省略内容

当 Sidepanel 组件拿到选中文本,执行与 Obsidian 插件相似的逻辑:文本转换向量并作近似搜索。所以不再添加代码,有兴趣可前往阅读:inbox/chrome/chrome-wenko/src/sidepanel/SidePanel.tsx

文本解读生成

文本解读生成同样放在 Sidepanel 组件。单独拎出是因为其可探索的点比较有趣。该功能可以理解为轻量级的RAG,通过组合目标文本与关联历史文本,构建提示词,交由AI解读。

相关实现分几个步骤,由于上一步我们已经拿到关联文本,这里就从手动组织提示词模板开始:

const getPrompts = () => {
  return `
【智能解析任务】
基于用户选中的文本片段「${selectedText}」,结合下列${Math.min(matchResults.length,5)} 条上下文线索,进行多维度语义解析。注意:
1. 若上下文存在矛盾信息,需标注差异点并解释成因 
2. 涉及专业术语时,须构建领域知识图谱关联 
3. 区分文本的字面逻辑与潜在表达张力 
 
【上下文线索】
${matchResults.slice(0,5).map((item,index)  => 
  `线索${index+1}: ${item.content}`
).join('\n\n')}`;
}

由于本文的AI解读是设计为一个类似Chat的流行交互,所以选择使用 @microsoft/fetch-event-source 库处理 SSE 文本流(服务器发送事件)。

await fetchEventSource('http://localhost:8080/chat', {
  method: 'POST',
  headers: {
	"Content-Type": "application/json"
  },
  body: JSON.stringify({
	model: "qwen/qwen3-32b:free",
	messages: [
	  {
		role: "user",
		content: `${getPrompts()}`
	  }
	]
  }),

服务端返回的数据同样遵循 Server-sent Events 规范。

// 设置响应头
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")

// 省略代码...

// 请求 openrouter 提供的 qwen/qwen3-32b:free
req, _ := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", 

// 省略代码...

fmt.Fprintf(w, "id: %d\n", eventId)
fmt.Fprintf(w, "event: %s\n", eventType)
fmt.Fprintf(w, "data: %s\n\n", dataBytes)
flusher.Flush()

在交互界面,对 SSE 数据流作如下处理:

onmessage(line) {
	try {
	  const parsed = JSON.parse(line.data)
	  if (parsed?.type === 'statusText') {
		setLoadingText(parsed?.content)
	  }
	  if (parsed?.content && parsed?.type === 'text') {
		setInterpretation(prev => prev + parsed.content)
		setLoadingText('')
	  }

到这里可以说是一切就绪:一个简单的 RAG 应用。但是后续才是有意思的地方。 对比一个标准的 RAG 应用,我们了解到:

步骤我们的实现标准实现
检索向量近似搜索返回若干历史文本向量库/全文检索返回相关文档片段
增强手动设计提示词模板自动构建上下文提示
生成由通用AI模型生成角度专用微调或者约束生成
可从技术角度角度展开讲的点很多,譬如:
  1. 引入更多数据构建层次化索引,例如原始文本-智能摘要-自定义属性,甚至包含Obdiain的知识图谱;
  2. 开发面向跨端、多模态的收集器,以期收集更多更实时的知识片段;
  3. 开发更多生成结果的呈现交互,例如 Anki 卡片、LaTeX 片段和思维导图等;
  4. 按照业务领域定制专业的提示词模板,以期带来更加精准、实际的价值;
  5. 更多可展开的...

但这里只是抛个砖头——我想讨论技术以外的话题: 在完成上述工作以后,赛博仓鼠就能安枕无忧了吗?不是的。 我们完成的工作仅仅是第一步:从知识输入(整合文本碎片)到处理(近似搜索)。AI解读看起来更像是一个「知识缝合怪」。仓鼠如何消化这些坚果,将其内化为知识能量,是值得更加深入讨论、也是更有想象力的过程。以终为始,举个例子:我如何借助费曼学习法将知识内化?围绕它可以尝试借由当下流行的 AI 写作工具(或者更好的产品形态),再者通过 MCP 将坚果碎片输送到更多的场景。