本地书签管理新思路:Go + SQLite FTS5 实现毫秒级全文搜索 📅 发布时间:2026/9/16 8:11:24 👁 浏览次数: Colibri 是法语里蜂鸟的意思。在我这边它也是最近折腾的一个小工具的名字——一个只做一件事的本地书签管理器把浏览器里攒了多年的几千条链接全部导入进来然后让我在任何时候都能用一两秒时间把它们搜出来。之所以叫这个名字是因为我对它的期待和蜂鸟一样小、快、不占地方。这篇文章我会把这个项目的来龙去脉讲一遍从为什么不想用现成方案到选型、实现、压测再到实际用了三个月踩过的坑尽量把每一步的取舍都说清楚。如果你也是那种收藏了几千条、却几乎从没再打开过的书签囤积症患者这个项目多少能给你一点启发。1. 书签越存越多却越难找到Colibri 想解决的恰恰是这个1.1 浏览器自带书签为什么够用但难用先别急着做新工具得先把痛点想明白。浏览器自带书签最大的问题是它能存但不好找。搜索基本只匹配标题和 URL不匹配正文内容。你当时收藏那篇文章是因为里面有个关键观点可标题早就忘光了搜不出来就是搜不出来。文件夹分类需要手动维护时间一长全是未命名文件夹 7待读 28。分类本身就是一种认知负担越攒越乱。跨设备同步依赖厂商账号浏览器一换数据迁移麻烦得想摔键盘。书签栏变成扩展图标停车场真正有用的东西被挤得看不见。这些问题不是忍一忍就能过去的。收藏得越多检索成本越高直到某天你发现收藏夹已经变成了一个只能进、不好出的仓库。1.2 从收藏家到捡垃圾我自己的体验我属于重度收藏用户写方案找参考链接、读技术文章顺手存一篇、看到好工具马上丢进书签每个月至少有百来条新增。几年下来估算一下累计超过一万条。真正的问题出现在一次写周报前。我明明记得几个月前看到过一篇关于性能优化的文章里面有个结论对我当时的工作很关键但标题是什么、哪个网站发的、大概什么日期全忘了。浏览器自带搜索按性能优化试了几轮翻出几百条不相关的东西最后只能放弃。那一刻我意识到工具不该是这样的。收藏的初衷是以后用得上如果以后根本找不到收藏这个行为就失去了意义。我需要的不是更庞大的资料库而是一只能精准悬停的蜂鸟小、快、准。1.3 蜂鸟原则小巧轻盈但反应极快蜂鸟的体型在所有鸟类里算是极小的一档体重通常只有几克翅膀每秒能扇动几十下可以在空中定点悬停去吸花蜜。这个特点映射到工具上特别有意思体型小不搞虚拟化、不搞容器编排一个可执行文件就是全部。反应快启动毫秒级搜索毫秒级不给用户等待的借口。定位准没有几十个菜单没有复杂的设置项打开就能搜、搜到就能跳。Colibri 这个名字就是这么来的。下面每一步设计我都在用这套标准问自己这个功能值不值得增加体积这个依赖会不会拖累启动速度这个东西在真实场景下真的能帮到人吗2. 选型不是赶时髦而是做减法Go SQLite FTS5 的组合逻辑2.1 为什么不是浏览器插件、在线服务或重型自托管动手之前我认真把市面上现有的方案捋了一遍做成对比就能看得很清楚。方案方向代表优势我认为不可接受的地方浏览器插件各类 Bookmark Manager 扩展安装简单、数据在本地绑定特定浏览器侧边栏/弹窗使用不够顺手搜不了正文内容在线服务Pocket、Raindrop 等多端同步、界面漂亮数据在别人服务器上免费版功能受限哪天服务停服数据可能拿不回来重型自托管Wallabag、Shiori 等功能完整、开源可自控需要维护数据库和服务端有的还依赖 PHP/Node 环境对只想快速检索书签来说太重Colibri 的方向我自己的方案单文件、本地存储、毫秒级搜索功能上只做导入 存储 搜索 跳转选型的本质是取舍。我要解决的核心场景很单一快速导入、快速搜索、快速打开。所有与这三个目标无关的功能都砍掉。2.2 单二进制和 SQLite 到底赢在哪一步决定用 Go 编译成单个可执行文件核心原因是部署和迁移太省心了。没有运行时依赖没有环境变量配置没有 YAML 配置文件迷宫。把二进制丢到服务器、NAS、或者自己的笔记本里双击就能跑。数据全部存在同目录下一个.db文件里备份就是拷贝文件迁移就是拷贝到另一台机器再打开一次。数据库选 SQLite 而不是 MySQL、PostgreSQL理由也很直接这是单用户、本地优先的场景。SQLite 是嵌入式数据库不需要独立进程不会占用几百兆内存查询性能对于几万条数据完全足够。换句话说这是一个杀鸡不要用牛刀的标准案例。如果你愿意花半小时去搭一个 PostgreSQL 来做个人书签检索当然也可以但你要多维护的东西会直接违背蜂鸟原则。2.3 搜索方案直接选 FTS5数据规模决定技术选型作为写过一段时间搜索相关代码的人我承认第一反应也想过上 Elasticsearch 或者 Meilisearch。但冷静下来算了一笔账我的数据量是几万条书签不是几亿条文档。为这个数据量去维护一个独立搜索引擎属于典型的用牛刀杀鸡而且会多出两三个常驻服务。SQLite 从 3.x 版本开始自带 FTS5 全文搜索扩展支持 BM25 排序、前缀查询、短语查询还能自定义分词器。更关键的是它在我的 Go 程序里只是一个嵌入式库不需要额外启动任何进程。用 FTS5 还有一个好处书签主数据表和全文索引表可以放在同一个数据库文件里用触发器保持同步事务一致性天然有保证。注意决定技术栈之前先算清楚自己的数据量级。数据在百万级以下嵌入式方案通常比独立服务更合适少一个需要运维的东西就少一个半夜被电话叫醒的理由。3. 落地实现书签存取、中文搜索与 HTML 导入的关键代码真正写代码之前我先明确了三个目标导入要快、搜索要准、前端要轻。整个项目其实只包含两个部分一个 Go 写成的后端服务加上一个几乎没有框架依赖的静态页面。3.1 建表书签主表与 FTS 索引怎么配合书签主表用来存数据FTS 虚拟表用来做快速检索。主表结构尽量简单不要过早设计一堆字段。我实际用的建表语句如下CREATE TABLE IF NOT EXISTS bookmarks ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, title TEXT NOT NULL DEFAULT , description TEXT NOT NULL DEFAULT , created_at DATETIME NOT NULL DEFAULT (datetime(now)), updated_at DATETIME NOT NULL DEFAULT (datetime(now)) ); CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(title, description, url, contentbookmarks, content_rowidid);这里有个很多人忽略的点contentbookmarks这代表 FTS5 使用外部内容表模式。索引表本身不冗余存储原始文本内容来自主表这样能避免两份数据占双倍磁盘空间。当主表数据更新时通过触发器告诉 FTS 索引哪些行的内容变了。URL 字段加了UNIQUE约束这个设计在导入重复书签时非常重要后面专门讲。3.2 触发器设计让全文索引保持和主表同步外部内容表模式下必须用触发器维护 FTS 索引否则主表插入新数据后却搜不到。三个触发器分别处理增、删、改CREATE TRIGGER IF NOT EXISTS bookmarks_ai AFTER INSERT ON bookmarks BEGIN INSERT INTO bookmarks_fts(rowid, title, description, url) VALUES (new.id, new.title, new.description, new.url); END; CREATE TRIGGER IF NOT EXISTS bookmarks_ad AFTER DELETE ON bookmarks BEGIN INSERT INTO bookmarks_fts(bookmarks_fts, rowid, title, description, url) VALUES (delete, old.id, old.title, old.description, old.url); END; CREATE TRIGGER IF NOT EXISTS bookmarks_au AFTER UPDATE ON bookmarks BEGIN INSERT INTO bookmarks_fts(bookmarks_fts, rowid, title, description, url) VALUES (delete, old.id, old.title, old.description, old.url); INSERT INTO bookmarks_fts(rowid, title, description, url) VALUES (new.id, new.title, new.description, new.url); END;删除触发器和更新触发器里的delete是 FTS5 的特殊指令用于从索引中移除某一行的数据。这个套路如果你去搜 FTS5 的文档能够看到官方推荐写法照着抄就行没必要自己发明。3.3 中文搜索trigram 与查询词转义搜索引擎最麻烦的就是中文分词。英文有天然空格FTS5 默认的 unicode61 分词器就能胜任。中文没有空格按字切分或者按词切分都有取舍。FTS5 从比较新的版本开始内置了trigram分词器它按三个连续字符滑动窗口来建立索引。对中文来说好处是不需要额外引入分词库搜索搜索引擎优化时可以匹配包含其中任意三字连续片段的内容。代价是两字以内的查询词无法匹配需要特殊处理。建表时指定分词器CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(title, description, url, tokenize trigram, contentbookmarks, content_rowidid);还有一个非常容易踩的坑FTS5 的MATCH语法支持AND、OR、NOT、引号和括号。用户输入的原始文本直接拼进MATCH就会导致语法错误比如你搜一个带双引号的短语、或者含有连字符的 URL。所以任何用户输入进入查询前都要做一遍转义func escapeFTSQuery(s string) string { s strings.TrimSpace(s) s strings.ReplaceAll(s, \, \\) // 处理 OR / AND / NOT 等由用户输入的查询关键字 s \ s \ return s }这样处理后用户搜如何 优化会变成如何 优化作为一个整体短语不会再触发语法错误。代价是用户无法使用高级语法但对个人书签搜索来说精确短语匹配远比能写复杂查询表达式更重要。3.4 解析浏览器导出的书签 HTMLChrome、Firefox、Edge 导出的书签文件都是 Netscape Bookmark File Format结构是一堆嵌套的DLDT节点。直接用golang.org/x/net/html解析抓到所有a标签就能拿到 URL 和标题func parseNetscapeHTML(r io.Reader) ([]Bookmark, error) { doc, err : html.Parse(r) if err ! nil { return nil, err } var result []Bookmark var walk func(*html.Node) walk func(n *html.Node) { if n.Type html.ElementNode n.Data a { var href string for _, attr : range n.Attr { if attr.Key href { href attr.Val } } title : strings.TrimSpace(nodeText(n)) if strings.HasPrefix(href, http://) || strings.HasPrefix(href, https://) { result append(result, Bookmark{URL: href, Title: title}) } } for child : n.FirstChild; child ! nil; child child.NextSibling { walk(child) } } walk(doc) return result, nil }这里过滤了javascript:、chrome://、about:这类伪协议。导入的时候我用INSERT OR IGNORE遇到重复 URL 就直接跳过等于天然去重。标题为空的那条用 URL 兜底避免搜索结果里出现一个光秃秃的链接。3.5 用 go:embed 把前端塞进二进制实现单文件部署如果前端文件在二进制外面那单文件部署就是空话。Go 的embed包可以把静态文件直接打进二进制里import embed //go:embed static/* var staticFS embed.FSHTTP 服务建议直接用标准库不引入 Gin 之类的框架。整个项目就两个路由一个/提供静态页面一个/api/search处理搜索请求。Go 标准库自带的net/http在高并发和路由匹配上对我的场景绰绰有余。前端页面同样走极简路线一个搜索框、一个结果列表、一份原生 JavaScript不引 npm 包。这样编译出来的二进制只有十几兆静态资源和后端都在里面拷到哪都能跑。4. 压了一万条书签进去之后的真实数据4.1 测试环境与数据准备理论说得再好也要看实际跑起来怎么样。我的测试环境是一台老掉牙的笔记本Intel i5-8250U8GB 内存普通 SATA SSD。系统是 Ubuntu 22.04Go 版本 1.21使用的 SQLite 驱动是 modernc.org/sqlite纯 Go 实现便于交叉编译。测试数据来源是我自己的真实书签导出加上从 Common Crawl 随便拉了部分 URL 补足数量最终导入 12000 条书签。这个数据量对于个人用户来算偏大能看出真实性能上限。4.2 导入耗时、文件体积、内存占用与查询延迟我把实测数据整理成一张表数字都是取三次测试的平均值仅供参考指标实测结果备注导入 12000 条书签约 1.8 秒包含 HTML 解析、URL 规范化、批量插入数据库文件体积3.4 MB 主库 2.1 MB FTS 索引trigram 索引比主库还大正常现象二进制体积约 16 MBmodernc.org/sqlite 是纯 Go体积稍大空闲内存占用约 28 MBRSS 实测三字以上中文搜索平均 6 ms12000 条数据下毫秒级两字词搜索LIKE 兜底平均 45 ms全表扫描但数据量小所以也可接受启动速度我没单独测因为体感就是命令敲完就起来了一个不加载任何远程依赖、没有握手过程、没有动态依赖库的静态二进制启动不会超过 50 毫秒。4.3 和浏览器自带搜索的一次正面比较我特意找了一批测试书签包含标题、URL、description 三种来源的关键词分别用 Chrome 书签搜索和 Colibri 搜索同样的词react performance optimization。Chrome 的搜索结果全部来自标题和 URL匹配到 3 条其中 2 条还是无关的。Colibri 的 FTS5 搜索因为把 description 也加了进来匹配到 11 条并且按 BM25 相关性排序明显更接近我记忆中的那篇文章。这个结果其实不意外。浏览器书签本身没有全文索引也没有给书签正文做相关性排序。区别的根本原因在于我设计了 FTS 索引而浏览器没有。只要数据量不太夸张FTS5 在这种场景下就是降维打击。5. 使用三个月后踩过的坑以及每一步的补救方法5.1 用户输入里的引号和连接符随时让 MATCH 崩溃第一个坑是搜索框里输入C或者xxx的时候前端直接报错。原因就是 FTS5 把、-、引号、括号都当作语法符号解析。我在 3.3 里已经写了escapeFTSQuery但第一次实现的时候没有处理结果同事随手搜了个C 教程接口直接返回 500。排查过程很简单把用户输入原样打到 SQLite 控制台里跑一遍报错信息是fts5: syntax error near 问题立刻定位。修复方案就是把整个查询短语当作一个整体用双引号包起来再替换内部双引号。提示任何让用户直接触碰搜索语法的功能都必须做同样的输入防护。这不是安全问题是语法正确性问题。FTS5 的 MATCH 语法是给开发者用的不是给普通用户用的。5.2 两字以内中文词查不到得用 LIKE 兜底trigram分词器有个硬性限制查询词必须至少 3 个字符。用户输入并发性能这种两字词MATCH 查询会直接报错或者返回空集。第一次遇到这个问题时我先把报错补丁打了然后加了一个分支查询字符串去掉引号后长度小于 3就退化成LIKE %关键词%在 title、description、url 三列上做全表模糊匹配。对一万多条数据而言这个 LIKE 查询耗时 40 到 80 毫秒完全能接受。这也是蜂鸟原则的一个体现不为 1% 的场景去引入一个重型分词库选一个 80% 场景都足够快的方案剩下 20% 用更朴素的兜底。5.3 database is locked写完才遇到的并发问题前端搜索接口用了防抖通常在 150ms 内只发一次请求所以查询冲突很少。真正的database is locked出现在导入大文件时导入过程动辄上万条的写事务如果此时用户正在搜索读写并发就会上来。解决方案有两个建议两个同时做在打开数据库连接时设置 busy timeout让 SQLite 等待锁而不是立即报错。打开 WALWrite-Ahead Logging模式读写可以并发写和写之间互斥db, err : sql.Open(sqlite, colibri.db?_pragmabusy_timeout(5000)_pragmajournal_mode(WAL))开启 WAL 之后导入时搜索基本不会再碰到锁问题。另外补充一个备份坑WAL 模式会产生-wal和-shm文件备份数据库时不能只复制.db文件要么先执行PRAGMA wal_checkpoint(TRUNCATE);把 WAL 内容合并回主文件要么把三个文件一起备份。这个细节很容易被忽略等到数据丢了才想起来就晚了。5.4 重复书签和 URL 规范化导入几千条书签后我发现数据库里有不少长得一样但又不完全一样的 URL。比如http://example.com/page和https://example.com/page、带不带www、带不带结尾斜杠、链接里带#section锚点这种。从用户角度看它们大多是同一篇文章。我的处理是导入前先做一层 URL 规范化协议和域名全部转小写、去掉#锚点、去掉默认端口。配合数据库层的UNIQUE约束做到真正确认重复就跳过。规范化规则不能做太激进。比如去掉utm_追踪参数这种操作不同站点参数名千奇百怪处理不好反而把不同链接误判成同一个。我的建议是只做确定安全的那几步转小写、去锚点、去默认端口其余不要动。5.5 使用习惯上的调整少分类多搜索工具做好之后我还改了自己原来的收藏习惯。以前总是纠结这条放前端还是放工具类现在完全不分类导入后统一靠搜索。搜索框只有一个输入关键词、回车、点开链接。文件夹结构彻底消失之后收藏的心理负担小了很多收藏频率反而上来了。这个转变恰恰是 Colibri 的价值所在工具替你想怎么找你就只需要想存什么。6. 如果也准备做一个属于自己的蜂鸟级工具的三条建议6.1 这个项目还能怎么扩展我现在对 Colibri 的下一步计划很克制考虑过几个方向为书签自动抓取网页标题和描述这样导入时不用依赖浏览器导出的信息。给搜索结果做简单的去重和分组比如同一个域名的结果折叠到一起。增加导出功能一键导出成 Markdown 格式的书单方便在别的地方二次使用。做一个 15MB 以内的 ARM 版本部署到树莓派或软路由上作为家庭局域网里的共享书签服务。这些功能都会小步迭代每次只加一个加完用一段时间确认没有违背小快准的初衷再决定是否继续。如果某个功能需要引入一个大框架我会直接放弃。6.2 三条建议克制、真实测试、留好门第一定义清楚你的蜂鸟原则。做一个工具之前先写清楚三个关键词小到什么程度算小快到什么程度算快准到什么程度算准没有标准后面功能越加越多迟早变成一个四不像。第二用真实规模的数据测试。不要只用 10 条测试数据跑一遍就上线。从第一天起就把自己真实的一万条书签导进去索引体积、搜索延迟、内存占用全部在真实数据下才有意义。我最初就是拿少量数据测的觉得一切飞快等到压入全部书签才发现 trigram 索引体积比预期大不少。第三给数据留一条后路。本地优先的工具有个隐患一旦这个工具不维护了你的数据怎么办所以从一开始就要保证数据库是标准 SQLite 格式、有导出接口这样未来就算换工具也能把数据迁移出去。宁可现在多做一步不要让数据被困在一个闭源格式里。6.3 一点使用体会这个项目做下来我最大的收获不是会用了 FTS5也不是学会了 go:embed而是亲身体会到少即是多在软件世界里有多成立。市面上不缺功能庞杂的书签管理器缺的是一个让人没有心理负担、打开就想用、用完就能找到东西的小工具。蜂鸟为了在空中悬停每秒要用翅膀扇动几十次看起来一点都不省力但它从不让自己变得臃肿。我的 Colibri 也是这样它可能永远不会成为一个大而全的笔记系统但在我需要的那一瞬间它永远足够快、足够准。如果你手上也有一个攒了几千条但找不到的数字仓库不妨也试试用这种思路给自己做一只蜂鸟。