写在前面
本文作者是一个赛博仓鼠,囤积文章如山,打开率却堪比银行金库。 如题,赛博仓鼠是如何努力对抗碎片化阅读的呢? 先来看看几张成品:
从 Obsidian 收集高亮片段:
在 Chrome 浏览文章时发现类似的片段之后:
如果对某些结论感到兴趣,让 AI 结合此前收藏的若干线索进行解读:
上述呈现为我的场景定制,它能为我完成以下操作:
- 从 Obsidian 笔记软件收集知识片段
- 从 Chrome 浏览器收集知识片段
- 在 Chrome 展现历史关联并生成AI解读
第一二点是常见的笔记场景(Obsidian)和碎片化阅读场景(Chrome)。 第三点目标是「温故知新」:借助AI分析当前文本和历史关联片段,例如差异对比、字面逻辑和表达张力分析等。
本文的完整代码,我放在daijinru/wenko,作为爱好会长期更新。欢迎指正。 作为小白,做了自认为最容易入门的技术选型,如图:
准备工作
开发AI应用的最佳工具是什么呢?当然是AI应用本身。首先我们准备几个强力工具:
- 集成开发环境 VSCode + Copilot
- 作为技术顾问的纳米AI桌面端
- 负责 Text Embedding 的本地模型 nomic-embed-text
- 负责 AI 解读的免费模型 qwen3-32b:free
- 用于存储向量和文本数据的 MongoDB Atlas
此外,本文不作完整步骤复现,而是着重于几个关键过程与其技术复现:
- 在有限的硬件条件下运行轻量化、成本低的技术方案
- 如何快速开发各类工具以满足多种知识采集场景
- 简单的讨论知识碎片化
开始动手
动手环节是:
- 运行环境
- 文本向量化、存储和近似搜索
- 开发知识采集插件
- 文本解读生成
如果我们有基础,折腾起来应该不费劲。在动手过程,会存在些许技术选型的问题:我倾向于将本项目看作一个MVP(最小化可行产品),所以尽可能以快速完成目标为准。例如数据存储为何不选择 FAISS 或者 Elasticsearch 等更具技术性的方案——仅因为选用 MongoDB Atlas 后仅需数十行易于理解的代码,对小白较为友好。
运行环境
代码运行环境准备如下:
- Node.js 作为 Obsidian Plugin 和 Chrome Plugin 的运行环境
- Go 用于开发数据存取服务
- 运行
nomic-embed-text的 Ollama - 注册 MongoDB Atlas 服务,创建免费方案。
- 注册
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)
如果我们顺利的完成上述几个步骤,并积累了一定量的文本片段,接下来可以跑一遍近似搜索。 不要忘了先前提到的建立索引。
这里是近似搜索的简单实现:
// 省略数据库连接操作
// 向量近似搜索,提前在 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 提供的 onMessage 和 sendMessage 和原文页面作交互,譬如:
先往页面菜单注册一个「高亮」按钮,用于发送选中的文本:
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模型生成角度 | 专用微调或者约束生成 |
| 可从技术角度角度展开讲的点很多,譬如: |
- 引入更多数据构建层次化索引,例如原始文本-智能摘要-自定义属性,甚至包含Obdiain的知识图谱;
- 开发面向跨端、多模态的收集器,以期收集更多更实时的知识片段;
- 开发更多生成结果的呈现交互,例如 Anki 卡片、LaTeX 片段和思维导图等;
- 按照业务领域定制专业的提示词模板,以期带来更加精准、实际的价值;
- 更多可展开的...
但这里只是抛个砖头——我想讨论技术以外的话题: 在完成上述工作以后,赛博仓鼠就能安枕无忧了吗?不是的。 我们完成的工作仅仅是第一步:从知识输入(整合文本碎片)到处理(近似搜索)。AI解读看起来更像是一个「知识缝合怪」。仓鼠如何消化这些坚果,将其内化为知识能量,是值得更加深入讨论、也是更有想象力的过程。以终为始,举个例子:我如何借助费曼学习法将知识内化?围绕它可以尝试借由当下流行的 AI 写作工具(或者更好的产品形态),再者通过 MCP 将坚果碎片输送到更多的场景。