星标过的仓库再没打开过第二次,截图记得长什么样却翻不出来,书签埋在几层文件夹里。这些东西的共同点是存的时候顺手,找的时候只能靠运气。
喜鹊爱叼亮闪闪的东西回窝,然后忘了放哪儿,所以叫 magpie。按 Alt+Space 唤出面板,把还记得的那点描述打进去,或者丢一张图进去,回车。
本地跑,不联网,不扫全盘。

三个源

Tab 在三个源之间切,每个源有各自的索引方式和排序选项。

本地文件

手动加进来的目录,里面每个文件都能按名字找到,认识的格式连内容一起进全文索引。

78 种纯文本与代码扩展名,整篇读入,默认单文件上限 4 MB,设置里可调到 16、64 或不限
PDF 由 pdf-inspector 抽取文字,上限 64 MB;扫描件与乱码抽不出正文,仍按文件名留在索引里
docx、xlsx、pptx 按 zip 拆开,取出带文字的 XML 部分去掉标记
视频、压缩包、二进制只索引文件名
jpg、jpeg、png、webp、bmp、gif 不走文本,交给视觉模型

Shift+Tab 在「全部 / 文本 / 图片」三个范围之间切,找图时滤掉文档,找代码时滤掉图。

GitHub 星标

贴一个不需要任何 scope 的 token,整份星标列表同步下来:名字、描述、topics,以及完整 README(存储时截到 6.4 万字符)。

每次同步把星标列表整个拉一遍,一千条约十页,代价不高,而且这是可靠发现「取消星标」的唯一办法,增量接口不会告诉你少了什么。README 抓取并发 8,带 ETag 做条件请求,服务端回 304 就不传正文。是否需要重抓看 pushed_at 有没有变。

记录里存的「上次抓取时的 pushed_at」同时兼任「已抓过」的标记。没有 README、请求返回 404 的仓库因此也算处理完毕,不会每次同步都白请求一次;等它有了新提交,标记对不上才重试。

排序有相关度、星标时间、星数三种,Shift+Tab 循环。按星数排时重排的只是已命中的前若干条,不会放宽候选池,否则一搜就是一堆万星而与查询无关的仓库挤在前面。每行显示最后推送时间,荒废的项目一眼可辨。

浏览器书签

直接读本地书签文件,Chromium 系是 JSON,Firefox 是 places.sqlite,不走浏览器 API,不需要登录,不联网。

Chrome、Edge、Brave 走固定路径,但分支太多,硬编码清单迟早漏。所以还会扫一遍平台数据根目录,凡磁盘布局符合 Chromium 特征的(存在 DefaultProfile * 目录,其中含 Bookmarks 文件)一律收下,Vivaldi、Arc 以及小众分支无需逐个添加。所有 profile 一起读,按标题、网址和所在文件夹路径检索,语义匹配叠加其上。

索引

magpie 的一次索引

遍历用 ignore crate 的 walker,四个开关:不跟符号链接、跳过隐藏文件、.gitignore 生效、require_git(false)。最后一项的作用是让 .gitignore 在非 git 目录里同样生效,规则文件在而 .git 不在的工作目录很常见。

读取前先嗅探。前 8 KB 内出现 NUL 即判定为二进制,不把乱码灌进全文索引。扩展名不足以判断,.log 完全可能是二进制。

改动检测分两层,因为两件事的代价相差很远。扫描层比对 mtime 与大小,都没变就跳过,文件不打开;嵌入层算一个 FNV-1a 哈希,把文件名、路径、正文一起哈进去,值没变就不重算向量。文件被 touch 过、或移走再移回,扫描层会重新读它,嵌入层不动。图片没有正文可哈,指纹由路径、mtime、大小拼成。磁盘上已消失的文件按路径反查删除。

三个触发时机:启动、手动刷新、每三十分钟一次的定时器。

全文索引用 FTS5 的外部内容表,正文只存在原表,FTS 侧只有倒排结构,不留第二份副本;增删改由触发器同步,调用方无需在业务代码里维护索引。删除一个目录时,文件行、全文索引、向量经外键级联与触发器一并消失,「删除即彻底删除」这个说法不依赖任何手写清理逻辑。

检索

magpie 的一次检索

两路怎么合

关键词一路,语义一路,各取前 50 条候选,按名次融合。

不用加权求和,是因为 BM25 分数与余弦相似度不同量纲。BM25 的取值范围与语料规模、词频分布有关,余弦固定在 −1 到 1 之间,两者直接加权,系数只能拍脑袋定,换一批语料就得重调。倒数排名融合只取名次:一条结果在某一路里排第 r,贡献 1 / (60 + r + 1),几路相加。两路都靠前的会被抬上来,只在一路出现的也不至于被埋掉。

余弦值仍然参与,但权重是 0.005,只够在名次相同的两条之间破平局,不足以改变名次结构。

e5 是多语言模型,中文查询可以命中英文 README 和代码注释,反向亦然,覆盖一百多种语言。

关键词那一路

SQLite FTS5,unicode61 分词。查询串会重新组装:按空白切词,每词加引号后缀 *,以 AND 连接。前缀匹配用于边打字边出结果,输入到 embedembedding 已能命中。

BM25 带列权重,星标表为 bm25(repo_fts, 8.0, 4.0, 4.0, 1.0),对应名字、描述、topics、README。仓库名命中的分量是 README 正文命中的八倍,把一个词打成仓库名,通常就是在找那个仓库。

命中附带上下文摘要,取命中处前后 12 个词,两端加省略号。实现上由 FTS5 的 snippet 把匹配区间包在 \u{1}\u{2} 两个哨兵字符之间,前端再替换成高亮标记,好处是 SQL 不必吐 HTML,正文里的尖括号也不会被当作标签。图片行没有正文,snippet 返回 NULL,这段摘要因此是可选的。

长文与分块

长内容切块,每块 1600 字符、重叠 200 字符,一块一个向量,单文件上限 128 块。切分优先落在换行处,不硬切在句中。

检索时一个文件按其最佳块计分。这对两类内容影响明显:长文档中埋在第一百页的一句话仍能被捞出,不会因整篇均值偏低而沉底;README 很长的仓库也不会因稀释而输给一个只有三行说明、恰好词面对上的小项目。

README 另有一步处理:每块前面贴上仓库身份头,含全名、描述、topics、语言。切开之后中间某块可能通篇在讲某个 API 的参数,单独取出无法判断归属,贴头之后每块都可归属。嵌入之前 README 会先清洗,去掉徽章、图片、HTML 标签和链接地址(保留链接文字),代码围栏整段丢弃,代码提供的是噪声而非语义。

e5 要求查询和文档分别加 query:passage: 前缀。这不是风格问题,前缀错了向量空间就对不上。

向量放在内存里

向量不在查询时读库,而是全部常驻内存。分块之后一次按键要读几十 MB,边打字边查支撑不住。

存储形式是 L2 归一化的 f32 数组,作为 BLOB 存在 SQLite 里。归一化在生成时完成,因此相似度就是一次点积,没有开方和除法。几万块全量暴力比对在十几毫秒量级,召回率 100%,省掉近似索引及其带来的调参、重建与召回损失。真到规模撑不住的一天,sqlite-vec 落进同一个文件即可,不必更换存储。

文字与图片是两个向量空间,但两者的 id 集合天然不相交,一个文件要么是图片要么是文本,所以两路相似度不会混在一起比较。

模型未就绪时仍可使用。查询向量在代码里是可空的,为空就只走关键词一路。首次启动需下载约 700 MB 模型,其间搜索照常返回结果。设置里可把下载源换成 hf-mirror.com,直连 HuggingFace 不通的网络下可用。

图片

图片由 SigLIP 2 嵌入,文字与图片共享同一空间,两个方向都通。

文字找图:输入「海边日落」或任何语言的同义描述,匹配的照片与其他结果一同排出,带缩略图
图找图:拖入、粘贴或点选一张图,返回最相似的若干张,带余弦相似度百分比

这一层没有使用现成封装。fastembed 的 ImageEmbedding 对三维输出默认按 CLS token 池化,而 SigLIP 用 MAP head,没有 CLS token,套上去得到的向量是错的,且不报错,只表现为结果长期不准。所以这一层自行持有两个 ONNX session,自行做图像预处理与输出选择,池化键按 pooler_outputtext_embedsimage_embeds 顺序查找。文本侧固定 64 token,SigLIP 按该长度训练。

图片向量带模型标识,模型或权重文件一换,旧向量自动作废重算,不会让两个空间的向量混在一起比较。读取失败的图片写入一个零维标记,后续不再重试,一张坏图也不会卡住整轮索引。

缩略图是 96 像素的 JPEG,随文件行存储,结果列表直接取用;拖入的查询图另生成一张预览,不入库。

只读你指定的目录

magpie 只索引显式添加的目录。目录内递归,但隐藏文件跳过、.gitignore 生效、符号链接不跟出所选目录。添加时拒绝嵌套与重复,否则两个目录互相包含会使同一文件进入索引两次,删掉其中一个时另一个的记录也会失准。

索引是用户目录下的一个 SQLite 文件。嵌入模型经 ONNX 本地运行,一次性下载完成后可完全离线。书签直接读本地文件。没有数据发出。

设置里能看到实际索引了哪些目录。删除一个目录,其文件记录、全文索引、向量一并抹除。每个目录和星标索引各有独立的重建按钮,怀疑某块索引损坏可单独重建,不必推倒整库。

回车「打开文件」会先校验路径确实落在某个已注册目录内,不在则直接拒绝;通过之后也只是在资源管理器或访达中定位该文件,不执行它。搜索结果本就出自自己的库,这两道校验是冗余的,但一个搜索工具最不该做的事就是替人运行一个他只想找到的文件。

用法

Releases 取构建:Windows 的 NSIS 安装包,macOS 的 dmg(Apple Silicon),Linux 的 AppImage、deb、rpm。

macOS 的包未签名,浏览器下载的 dmg 会被 Gatekeeper 拦下。用 curl 安装可绕开隔离属性:

sh
curl -L https://github.com/newdee/magpie/releases/latest/download/magpie_aarch64.app.tar.gz | tar xz -C /Applications

已经被拦下的,右键「打开」,或执行 xattr -cr /Applications/magpie.app

自行编译:

sh
pnpm install
pnpm tauri dev      # 开发
pnpm tauri build    # 出包
cargo test -p magpie-core

需要 Rust、Node 加 pnpm,以及 WebView2 或 WebKit 运行时,Windows 11 与 macOS 自带。

键位:

Alt+Space 唤出或收起,可在设置里另录一个
PgUp PgDn 翻结果
Enter 打开,仓库和书签进浏览器,文件在资源管理器或访达中定位
Ctrl+Enter 把输入交给浏览器,形似网址则直接打开,否则搜索
Tab 换源,Shift+Tab 切本地范围或星标排序
Esc 依次清除图片查询、关闭设置、收起窗口
拖入、粘贴或点选一张图,进入以图搜图

面板位于屏幕上方居中,置顶于所有窗口,失焦不隐藏,否则拖放无法完成;拖标签条可移动位置。空查询时本地源列出最近的文件,星标源列出最近标记的仓库,可当浏览视图用。

设置项:GitHub token 与连接状态、索引目录的增删与重建、外观(跟随系统、浅色、深色)、唤出快捷键、模型下载源、单文件大小上限、模型下载进度、版本号。

限制

扫描件 PDF 与截图中的文字目前读不出,这类 PDF 只能按文件名找到,OCR 尚未实现。
没有预览面板,命中之后只能打开外部程序查看。
macOS 的包未签名也未公证,安装需多一步。
向量为暴力比对,几万块无压力,堆到几十万需换检索结构。
Twitter 的喜欢只能走数据导出的归档文件,官方 API 现已收费。

代码是 MIT,Rust 核心在 core/,Tauri 壳很薄,前端只有一个窗口。起因是星标列表只能按名字搜,而人记得住的通常是「那个用 Rust 写的、干什么用的东西」,偏偏想不起来叫什么。现在把那句话打进去能出来,暂时就够用了。