
做个人项目这几年我试过用各种工具管理笔记和碎片信息从在线文档到各种双链笔记折腾一圈之后最深的感受是工具越复杂我记录东西的欲望反而越低。这个月初我开了个新坑代号叫 Madeira一个本地优先的纯文本知识管理工具。名字取自马德拉岛那种陈年酒我希望自己的笔记系统也能像它一样放得越久越有味道。Madeira 做的事情其实很简单它只盯着一个装满 Markdown 文件的文件夹监听文件变动解析正文里的双向链接和标签然后生成反向链接、相关笔记和全文索引最后提供一个本地网页让你检索和浏览。我不打算再造一个 Electron 大壳子也不想把数据锁在任何云服务里。数据就是你自己磁盘上的 .md 文件关掉程序它们也还在。如果你也受够了在线笔记的卡顿、格式漂移和平台迁移焦虑又觉得 Obsidian 这类工具的界面和插件体系有点过度包装那这个项目可能会给你一些启发。这篇文章我会把设计思路、核心解析逻辑、常见坑和排查方法都摊开讲从零开始写一个够用的版本。无论是想复刻一个轻量笔记系统还是只想给自己的静态博客加个双链功能你都能在这里找到能直接抄作业的东西。1. 项目概述与设计思路1.1 为什么叫 Madeira它到底要解决什么问题最开始凭着一股热情连续写了两周笔记我打开自己的仓库时还是傻眼了文件夹里躺着三百多个 Markdown 文件文件名从未命名-1到临时想法-37不等标签用了一百多个很多内容互相有关联却完全没有链接。我意识到问题不在于我不够自律而是工具没有帮我把散落的碎片自动连起来。当时我手上有什么目录里是浓得化不开的 Markdown 文件脑子里是清清楚楚的知识地图中间缺的恰是一个能自动构建“知识网络”的胶水层。市面上的双链笔记工具不是不好但我一直有几点不舒服有的用私有数据库存笔记导出来格式全是乱的有的启动要加载好几个 G 的 Electron 运行时有的太依赖插件市场哪天作者不维护了插件生态就凉了。Madeira 的直接定位就是“文件即数据库”目录本身就是仓库笔记就是普通文本程序只是用来扫描、索引和展示的附加层。这个项目解决的核心问题有三个。第一是自动建立反向链接我写笔记时只需要用[[笔记名]]指向别人程序负责在所有笔记里找出谁指向了当前这篇生成一个“被引用列表”。第二是标签去重和自动归类我只负责给笔记打标签程序会统计标签出现频率并把共享高频标签的笔记聚成一类。第三是可靠的全文本地搜索不需要联网不需要上传面对几千个文件也能在一秒内出结果。适合从 Madeira 里得到参考的主要是两类人。一类是长期积累 Markdown 笔记、文档和博客草稿的写作者另一类是正在做本地优先工具或想了解双链解析细节的开发者。前者可以把这个项目当成一个私有 wiki 的雏形后者可以把它当作一个结构清晰的迷你搜索引擎来读。1.2 四条设计原则第一个原则是本地优先。所有数据读写只发生在本地磁盘没有账号、没有同步、没有订阅。数据安全的底线不取决于某个公司的服务器而是取决于我自己的备份策略。我需要这个程序二十四小时跑在后台却不想它联网发送任何东西。第二个原则是纯文本优先。一条笔记只有一个.md文件文件头部用 YAML Frontmatter 存元数据正文用标准 Markdown 语法写内容。选择 Markdown 的另一个好处是生态完整VS Code、Obsidian、Typora、Sublime 都能直接打开编辑哪怕有一天 Madeira 不维护了这些文件依然是干净的、可迁移的。第三个原则是渐进式增强。不给编辑器做插件不侵入写作流程。我的理念是写的时候只管写剩下的链接分析、索引构建、效果展示全部交给定时任务或文件监听来做。这样就算用户不打开界面后台索引也会保持新鲜。第四个原则是可组合性。程序拆成两个部分一个核心引擎库负责扫描、解析和索引一个可选 Web 界面负责展示。引擎库暴露 Node.js API 和 REST API这样任何人都能在它上面写编辑器插件、生成周报、做统计而不是被迫接受我的界面审美。我把这四条原则写进了项目 README 的第一段后续每一行代码都拿它们对照审查。事实证明当工具演进过程中出现“增加云同步”“增加数据库存储”之类的冲动时这些原则就是最好的否决理由。1.3 整体架构与数据流整体架构不复杂核心是一条单向数据流文件监听器捕获磁盘变动解析器把 Markdown 转成结构化笔记对象索引器维护内存倒排索引和链接图API 层对外提供查询与浏览能力。更具体地说启动后流程是这样的扫描笔记根目录拿到全部.md文件的路径列表。逐个解析文件提取 Frontmatter 元数据、标题、正文、标签、双链目标。建立全局 Map笔记ID - 笔记对象另外维护链接目标 - 笔记ID列表的反向索引。用笔记正文构建倒排索引供全文检索使用。启动 chokidar 监听目录变化文件新增、修改、删除时增量更新内存结构。启动 Fastify 本地服务提供/api/notes、/api/search、/api/graph等端点。提供静态页面展示目录树、笔记详情、反向链接和相关笔记。为什么不用数据库我算过一笔账个人知识库的笔记量通常在三千到两万篇之间每篇正文平均三千字全文读完也就几十兆字节。这个规模放在内存里毫无压力。内存索引的查询速度比任何磁盘数据库都快一个数量级而且省掉了数据库迁移、版本兼容的麻烦。把整个知识图加载到内存换取毫秒级响应对本地工具来说是非常划算的买卖。2. 核心功能拆解与关键实现2.1 双向链接的解析与反向链接生成双向链接是 Madeira 最喜欢讨论的功能但它的核心原理比想象中简单。正向链接就是我在正文里写的[[目标笔记标题]]反向链接则是在全仓库范围内查找所有指向当前笔记的[[当前笔记标题]]。写笔记的人只负责正向引用反向关系由系统自动推导。具体实现上要注意几点。首先不能只做字符串正则匹配因为标题里可能带[]、|别名、#锚点。我的解析策略是先用 markdown-it 把正文转成 AST然后遍历 token 节点只有text类型的节点才用双链正则二次扫描。这样能避免匹配到代码块或行内代码里的内容。双链正则我调整了很多次最终稳定版本是这样const WIKI_LINK_RE /\[\[([^\[\]|#])(?:#[^\[\]|])?(?:\|([^\[\]]))?\]\]/g;这个正则支持三种写法[[目标笔记]]、[[目标笔记#锚点]]、[[目标笔记|显示文字]]。分组一取目标标题分组二取显示别名锚点部分不额外捕获。解析完成后下一步是生成反向链接索引。我用了一个Mapstring, Setstringkey 是归一化后的笔记标题value 是所有引用该标题的笔记 ID。每次解析完一篇文章就遍历它提取出的全部双链目标把当前文章 ID 塞进对应 Set。查询某篇文章的反向链接时只要拿当前文章的标题去这个 Map 里取 Set再逐个加载笔记对象即可。有两点容易踩坑。一是标题归一化必须一致解析链路里统一用小写并去掉首尾空格否则[[Foo]]和[[foo]]会被当成两个不同的目标。二是别名处理要保留原文显示别名只影响界面展示链接目标仍然以|之前的部分为准。2.2 标签系统与自动归类逻辑标签看起来简单但真正做起来会发现“如何统计、如何聚类”才是重点。我支持两种标签写法Frontmatter 里的tags: [a, b]列表以及正文里#标签名的行内标签。解析时把它们全部合并进一个Setstring避免同一篇笔记里重复计数的尴尬。自动归类逻辑我用了两层策略。第一层是前缀分组如果标签名里带了/这种层级符比如项目/博客重构、项目/公众号就把同一前缀下的笔记视为同一个集群。这一层很好用因为它符合大部分人打标签的习惯解释起来也直白。第二层是链接相似度聚类如果笔记 A 和笔记 B 共享至少两个标签并且互相之间有三条以上共同引用链接就把它们列为“相关笔记”。计算相关度时我用的是 Jaccard 相似度加一个简单的加权公式。比如笔记 A 的标签集合是 TA链接集合是 LA笔记 B 对应的是 TB 和 LB则相关分数按下面这样算score 0.6 * (|TA ∩ TB| / |TA ∪ TB|) 0.4 * (|LA ∩ LB| / |LA ∪ LB|)阈值我调成了 0.25低于这个值不展示到“相关笔记”区域。太小会让列表变成大杂烩太大会让大部分笔记没有相关项。你完全可以根据自己的语料库调整这个数字我认为最合适的方法是拿自己的笔记跑一遍人工看五十组结果再决定阈值往哪边靠。每篇笔记详情页里我会展示三块内容标签面板、反向链接列表、相关笔记列表。标签面板显示所有标签及全库出现次数点标签能看到这个标签下的全部笔记反向链接列表按最后修改时间排序相关笔记卡片显示标题和摘要摘要取正文前八十个字符。2.3 全文检索与增量索引全文搜索是这类工具最不能掉链子的部分。我没有直接引入 Elasticsearch而是自己写了一个非常朴素的内存倒排索引。索引结构大致是这样class InvertedIndex { private postings: Mapstring, Setstring new Map(); add(docId: string, tokens: string[]) { for (const token of new Set(tokens)) { if (!this.postings.has(token)) { this.postings.set(token, new Set()); } this.postings.get(token)!.add(docId); } } searchTerms(terms: string[]): Setstring { if (terms.length 0) return new Set(); let result: Setstring | null null; for (const term of terms) { const docs this.postings.get(term); if (!docs) return new Set(); if (result null) { result new Set(docs); } else { result new Set([...result].filter((d) docs.has(d))); } } return result ?? new Set(); } }关于中文分词我最初用的是按单字切分效果差到没法用。搜“马德拉酒”会被拆成“马”“德”“拉”“酒”匹配出一堆无关结果。后来换成了二元 bigram 切分也就是把连续两个字符当作一个 term比如“马德拉酒”切成“马德”“德拉”“拉酒”。这样精度提升很明显又不需要引大词库。英文则按空格和标点切分统一做小写归一化。增量索引是另一个大坑。文件监听器每秒钟可能收到几十个事件如果每个事件都全量重建索引CPU 会直接拉满。我用了一个两百毫秒的防抖窗口chokidar 收集到事件后不立即处理而是等这个窗口内没有新事件了再批量处理。批量处理时会先根据文件路径读取磁盘上的最新内容更新笔记对象、链接图、倒排索引三处结构。实测下来三千篇笔记的仓库保存文件后大约 0.3 秒就能看到链接和搜索更新。删除文件的处理更麻烦。索引不会自动知道文件没了必须监听unlink事件然后手动从所有 Map 和 Set 中移除该文档 ID同时还要在链接图的引用关系里把它指向别人的边删干净。这个逻辑很容易漏我会在后面的坑位记录里专门展开讲。2.4 Web 界面与 API 设计我没有给 Madeira 做桌面客户端。界面上只提供本地 Web 服务默认端口 8765浏览器打开就是完整的工作台。这个选择省掉了 Electron、Tauri 的打包配置也让界面代码和核心引擎彻底分离。API 设计上我只暴露六个端点够用且不过度设计端点方法说明/api/notesGET按目录返回全部笔记 ID、标题、标签/api/notes/:idGET返回单篇笔记完整内容与元数据/api/notes/:id/backlinksGET返回该笔记的反向链接列表/api/notes/:id/relatedGET返回相关笔记列表/api/search?q关键词GET全文搜索返回匹配笔记标题和摘要/api/tagsGET返回全库标签及出现次数首页用一个 300 行的原生 HTML 少量 JavaScript 实现不引入前端框架。左侧是文件目录树点击目录加载对应笔记列表中间是笔记正文用 marked 在浏览器端渲染右侧是标签、反向链接和相关的两个面板。界面丑归丑但胜在零依赖、加载快维护它不需要跑 node_modules 里的三千个包。这种做法有人会觉得太简陋但我反而认为它最符合个人知识工具的定位。界面只是个浏览器真正聪明的大脑是磁盘上那些 Markdown 文件是链接图是标签集合。哪天我嫌界面难看了写一个新的前端项目去调同一套 API 就行引擎完全不受影响。3. 实操过程与避坑指南3.1 初始化项目与核心技术选型我一开始就把技术栈定成 Node.js TypeScript最核心的依赖只有四个。文件监听用chokidarHTTP 框架用fastifyMarkdown 解析用markdown-it搜索没有用外部库索引逻辑自己写。之所以不选 Lunr、FlexSearch 这类现成搜索库是因为我的搜索需求很简单而自己写能完全掌控索引更新时机出现问题时也知道去哪查。目录结构如下madeira/ package.json tsconfig.json src/ index.ts # 入口负责启动服务 scanner.ts # 目录扫描与文件监听 parser.ts # Markdown 解析 indexer.ts # 索引构建与查询 linkGraph.ts # 双向链接图 api.ts # REST API 路由 view/ index.html # 单页界面初始化时我用tsx跑 TypeScript省去编译步骤开发体验清爽很多。生产环境则用tsc编译成 JS。如果你要复刻我建议开发依赖只加typescript和tsx就够了测试再加一个vitest。3.2 关键代码实现示例整个项目最核心的函数是parseNote它负责把一堆原始文本变成结构化的笔记对象。方法签名和主要逻辑如下interface Note { id: string; title: string; path: string; tags: string[]; links: string[]; rawContent: string; updatedAt: number; } async function parseNote(filePath: string): PromiseNote { const content await fs.promises.readFile(filePath, utf8); const { data, body } matter(content); const title data.title ?? path.basename(filePath, .md); const tags normalizeTags(data.tags ?? []); // 从正文中提取双链目标 const links extractWikiLinks(body); // 从正文中提取行内标签 tags.push(...extractInlineTags(body)); return { id: slugify(filePath), title: title.trim(), path: filePath, tags: [...new Set(tags)], links: [...new Set(links)], rawContent: body, updatedAt: (await fs.promises.stat(filePath)).mtimeMs, }; }这里有个关键点id不能直接用文件名因为不同目录下可能有同名文件。我用仓库根目录到文件的相对路径做slugify保证每个笔记 ID 全局唯一同时还能在渲染时还原成真实路径。反向链接生成逻辑放在indexer.ts里每次updateNote时先删除旧关系再插入新关系function updateNote(next: Note) { const prev notes.get(next.id); if (prev) { for (const target of prev.links) { backlinkMap.get(target)?.delete(prev.id); } } notes.set(next.id, next); for (const target of next.links) { if (!backlinkMap.has(target)) backlinkMap.set(target, new Set()); backlinkMap.get(target)!.add(next.id); } }这段代码体现了“先清理再插入”的更新原则。如果在更新前不清掉旧链接那么改名后的旧链接会一直残留在索引里造成页面出现指向不存在笔记的反向链接。曾几何时我漏了这个步骤排查了半天最后发现是删除逻辑忘了写。3.3 我踩过的五个坑第一个坑是中文文件名的 URL 编码问题。浏览器请求/api/notes/生活/2024-01-01%20目标.md时如果 ID 里有中文和空格服务端路由解析出来的字符串可能已经变了。解决办法是所有从 API 返回的链接和 ID 都统一用encodeURIComponent编码前端再用decodeURIComponent解码。第二个坑是文件时间戳不准。有些云同步盘会自动改变文件内容而保留 mtime导致我以为没改动索引却过时了。后来我在监听器里不只比较 mtime还会比较文件大小和内容哈希的前八个字符。这样能明显减少误判。第三个坑是增量更新时忘记了反向链接的删除。每次更新一篇笔记必须先从旧笔记对象的链接集合里把当前 ID 删掉再写新的链接集合。这个逻辑如果顺序反了就会出现“幽灵反向链接”。我把这个操作写成独立函数每次手动调用确保不会漏。第四个坑是 chokidar 的监听深度。默认配置只会监听当前目录不会递归监听子目录。个人知识库通常都是多级目录结构必须设置depth: 99或直接ignoreInitial: true 递归模式。否则子目录里的笔记永远进不了索引。第五个坑是防抖窗口和大量批量复制文件时的冲突。一次性复制五百个文件进仓库如果防抖窗口太短索引会被触发几十次界面卡成 PPT。我的解法是在批量事件发生时动态延长防抖时间到 2 秒等事件风暴结束再重建一次索引。4. 常见问题排查与扩展方向4.1 常见问题与排查速查表放在本地服务跑起来后你大概率会碰到下面这些情况。我把自己的排查过程整理成一个速查表方便你直接对号入座。问题现象可能原因排查思路修复方案启动后界面空白端口被占用或 API 返回异常看终端日志访问/api/notes是否正常杀掉占用端口进程或改PORT环境变量新增笔记没有链接和标签文件监听未递归子目录检查是否设置了递归监听为 chokidar 添加递归模式搜索“马德拉酒”没结果索引没更新看监听器日志防抖窗口是否过长手动触发一次全量重建或缩短防抖时间反向链接大量缺失更新时未删除旧链接关系用测试脚本模拟改名场景在updateNote中先清理旧链接页面卡顿、CPU 高防抖窗口太短导致索引频繁触发查看事件数量把防抖窗口提升到 500ms 以上中文文件名打开 404URL 编码问题看浏览器地址栏是否有乱码统一使用encodeURIComponent除了上面表格里的我还想特别提醒一个隐蔽问题不要用fs.watch直接做监听。在 macOS 和 Windows 上fs.watch的事件粒度不一样经常会出现事件重复或丢失chokidar封装了底层实现行为统一得多。4.2 我从这个项目里总结的经验写 Madeira 的过程里我最大的体会是“小工具也要有清晰的边界”。一开始我也想着塞进插件系统、主题切换、移动端适配后来全部删掉。核心价值只有一个把你的 Markdown 变成可以检索和关联的知识网络。围绕这个核心做深比堆功能有用得多。如果你也想做一个类似的项目我建议从最小闭环开始解析一篇笔记、生成一个反向链接、实现一次关键词搜索。三个功能跑通再考虑监听和界面。这样每一步都能验证效果不会在半路迷失。另外小项目也要写测试。我把解析器和索引器的主要逻辑用 Vitest 写了几十条用例几乎覆盖了双链的别名、锚点、缺失目标、更新删除这些边界。实测下来收益非常大因为后面每次重构都靠这些测试兜底。最后分享一个扩展玩法我把 Madeira 的引擎库引用到了一个静态博客生成流程里运行madeira build时输出一个graph.json再交给前端做可视化关系图。这样博客文章之间也能自动生成“相关阅读”读者体验立刻不一样。你完全可以把这套引擎接到任何 Markdown 仓库上不必局限于笔记场景。我在实际使用中最满意的瞬间是某天打开一个三个月前写了一半的笔记它自动列出了七篇后来写的相关文章。那些内容我早就忘了联系但链接图记得。这种让旧知识重新浮现的感觉就是我做这个项目最原始的动力。