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

Tab 在四个标签之间切:本地、星标、Web、剪贴板,顺序和默认打开哪个都能在设置里改。应用不单独占标签,在本地标签里作为置顶命中出现。每个源有各自的索引方式和排序选项。

本地文件

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

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

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

视频

视频不再只按文件名找。抽帧之后按内容搜,命中给到的是具体的时间段,带那一帧的缩略图,而不是「这个文件里有」。

每个视频的处理分四步。先让 ffmpeg 以 2fps、160 像素解出 RGB 帧,这一遍要便宜;然后对相邻帧算 4×4×4 分箱的 RGB 直方图,用卡方距离找分镜边界,阈值 0.4,纯 Rust 实现,没有引入 OpenCV,ffmpeg 是唯一的原生依赖;每个分镜取代表帧,取中点,长镜头每 20 秒再加一帧,单镜头最多 3 帧、单视频最多 200 帧;最后对每个代表帧再让 ffmpeg 定位一次,抓一张 480 像素的静帧,做成 96 像素缩略图并算 SigLIP 向量存起来。一个分镜至少要占两个采样点,也就是 1 秒。

代表帧的向量和图片走同一个空间,所以文字描述和拖进来的图都能落进视频里。检索时一个视频按它最好的那个分镜计分,跟文件按最佳块计分是同一套做法。

ffmpeg 的解析顺序是 PATH 上的系统 ffmpeg 优先,没有就自动下载一份静态构建,放在 magpie 自己的 release 里。两种情况失败都只反映成一行状态文字,不会崩。解码的线程数上限和要不要开硬件解码都能在设置里调,抽帧这件事在老机器上是真的会占满 CPU。

命中一个分镜之后回车,如果默认播放器是 VLC、mpv、PotPlayer 或者 MPC,会直接跳到那个时间段开始播。

应用

在本地标签里打字,匹配上的已安装应用作为置顶命中出现,带 App 标记,回车启动。来源是 Windows 的开始菜单、macOS 的 /Applications、Linux 的 .desktop 条目。

匹配按一个分数梯子排:完全相等 1.0,前缀 0.9 减去名字长度的千分之一(同样前缀时短名在前),子串 0.6,首字母缩写 0.5,vsc 命中 Visual Studio Code。缩写要求至少两个字母,并且只认词首字母,词边界是空格、连字符、下划线、点和斜杠,所以 code 不会命中 RecoveryDrive。

中文名走拼音。做法是把名字里每个汉字展开成一组可能的拼写,包含它的每个读音的全拼和首字母,非汉字的 ASCII 字符只匹配自己,标点作为可跳过项,然后拿查询串在这组选项上做回溯遍历。一次遍历同时覆盖全拼、首字母和二者混写,wxweixinweix 都能命中微信,txhy 命中腾讯会议。多音字是免费的,因为一个字的所有读音本来就都在候选里,cqzq 都能找到重庆开头的应用。查询短于两个字母直接不走这条路,否则一个字母会点亮所有同首字母的应用。从名字开头对上的拼音给 0.8,从中间对上的给 0.55。

拼音之外还有一层别名,解决的是「装的时候是一种语言、想起来是另一种」。内置一张双语名字表,同组里任何一个名字都能命中这个应用:微信和 WeChat、飞书和 Lark、腾讯会议和 VooV Meeting、哔哩哔哩和 B站、剪映和 CapCut 这类。表里只放稳定且公认的对应,其余交给用户自己写规则,形如「proxy = Clash for Windows」,左边是想打的词,右边按应用名的子串匹配。Linux 上还会把 .desktop 里的 KeywordsGenericName 一并收进别名。

GitHub 星标

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

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

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

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

Web

Web 标签把书签和浏览历史一起搜,Shift+Tab 在全部、书签、历史之间收窄。

书签直接读本地文件,Chromium 系是 JSON,Firefox 是 places.sqlite,不走浏览器 API,不需要登录,不联网。Chrome、Edge、Brave 走固定路径,但分支太多,硬编码清单迟早漏。所以还会扫一遍平台数据根目录,凡磁盘布局符合 Chromium 特征的(存在 DefaultProfile * 目录,其中含 Bookmarks 文件)一律收下,Vivaldi、Arc 以及小众分支无需逐个添加。所有 profile 一起读,按标题、网址和所在文件夹路径检索。

历史读的是 Chromium 的 History 库和 Firefox 的 moz_places,搜的是页面标题加网址,不只是地址。历史条目量大,所以每个 profile 只保留访问次数最高的 3000 条,既压住库的体积也压住嵌入的开销。

书签和历史这一路的关键词检索多加了一道子串补充。unicode61 分词只认词首前缀,所以打 sms 永远够不到 longsms.net 这种嵌在中间的片段。补的办法是在同样几列上再跑一次 LIKE 子串匹配,用户输入里的通配符先转义掉。另外同一个网址在多个浏览器里都存过(比如 Edge 和 Edge 的 SxS 版),结果会折叠成排名最好的那一行,候选阶段多取一些以保证折叠之后条数不缩水。

剪贴板

默认关闭。在设置里打开之后,复制的文本记进本地库,从剪贴板标签搜。这是唯一一个空查询也有用的源,不打字就列最近复制的东西。回车把选中的复制回去,Ctrl+Delete 删掉,Shift 加方向键多选,多选之后回车拼起来一起复制。可以按条数(500 / 2000 / 不限)和时长(7 天 / 30 天 / 永久)设上限,也可以整个清空。

重复复制同一段不会产生新条目,只把最后复制时间和次数往上加。太长的剪贴内容整条跳过而不是截断,理由写在注释里:一条粘贴回去和当初复制的不一样的记录,比一条根本没记的更糟。长文本进全文索引是完整的,但只有开头 2000 字符进向量。

复制的图片也进历史,带缩略图,而且因为走的是同一个 SigLIP 空间,可以靠描述找回来,比如「那个报错弹窗」。选中之后 Shift+Enter 直接粘回你唤出面板之前那个应用里,省掉先复制再切窗口的两步。

密码管理器标记为保密的内容不记录。Windows 上认的是 ExcludeClipboardContentFromMonitorProcessingCF_CLIPBOARD_VIEWER_IGNORE 这两个注册剪贴板格式,macOS 上认的是 org.nspasteboardConcealedTypeTransientTypeAutoGeneratedType。Linux 目前没有跨桌面环境的通行约定,所以这条在 Linux 上是空的,代码里直接返回假而不是假装支持。

索引

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,只够在名次相同的两条之间破平局,不足以改变名次结构。

历史条目还多一个访问次数加权,同样是这个量级:分数加上 0.001 × ln(访问次数)。取对数是因为访问次数的分布跨好几个数量级,不压一下会直接主导排序;常去的页面在相关度接近时挤到前面,仅此而已。

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 模型,其间搜索照常返回结果。

模型下载自己写了一个纯 HTTPS 的取件器,没用 hf-hub 的客户端。原因是那套协议依赖 ETag 响应头,而镜像站和中间设备经常把它剥掉,报出来就是一句 header etag is missing,加上它的元数据往返本身又多几种在恶劣网络下失败的方式。现在走的是 {endpoint}/{repo}/resolve/main/{path} 这个 huggingface.co 和所有镜像都按静态文件伺服的地址,一个文件一次 GET,重试之间用 HTTP Range 断点续传,下完原子改名。设置里换 hf-mirror.com 只是换 endpoint。

取件按三级回落:先 Hugging Face,不通就走镜像,两边都不通就去 magpie 自己 release 里存的那份副本,跟 ffmpeg 走同一条链。所有模型都这样,包括 OCR 的那两个。

频次与新近度

打开过的结果记进一张本地的命中统计表,键按类型定:文件和视频用路径,应用用启动目标,书签和历史用网址,仓库用 id。之后按频次和时间给一个 0 到 1 的因子,频次取对数、约十次用满,时间按三十天左右的指数衰减,两者相乘。

关键是这个因子的封顶值按源单独定,取决于那个源的分数尺度。应用那边的档位间隔是 0.1,封顶给 0.08;融合分数那几路的量级小得多,封顶给 0.01。这样习惯用的东西能在同档里往上走,但压不过一个更高的匹配档位,应用名完全命中永远排在被加权的前缀匹配之上。统计只在本地,也只记你真的打开过的东西,所以那张表一直很小,每次搜索查一次就够。

图片

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

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

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

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

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

截图里的字

图片里的文字可以单独抽出来搜,默认关闭,在设置里开。用的是 PP-OCRv4,检测和识别两个小 ONNX 模型加起来约 15 MB,中英文都认。识别的字表存在模型自己的 ONNX 元数据里,所以不需要额外的字典文件。检测那一侧的多边形后处理简化成了连通域加外扩矩形,对截图和文档这类版面够用,而本地文件索引看到的基本就是这两类。识别侧走贪心 CTC,带一个平均置信度下限。

抽出来的文字直接写进文件的正文列,所以全文索引的触发器和那套按哈希判断的嵌入流程原样接管,检索侧一行新代码都不用加。

这里带出一个原有的坑。unicode61 分词会把一整串中日韩文字当成一个词,所以纯 FTS 永远匹配不到 OCR 文本的中间片段,跟前面书签和历史撞的是同一堵墙。解决办法也一样,在文件名和正文上补一次 LIKE 子串匹配,高亮片段自己拼,按字符边界切以免截断多字节字符。

只读你指定的目录

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

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

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

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

用法

Releases 取构建:Windows 的 NSIS 安装包,macOS 的 dmg(Apple Silicon),Linux 的 AppImage、deb、rpm。Windows 也有 Scoop bucket 和 winget 清单,Arch 有 AUR 的 PKGBUILD。

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 切当前源的模式:本地范围(全部/文本/图片/视频)、Web 范围或星标排序
Ctrl+,Alt+, 在设置与搜索之间来回切
开关预览面板,光标在查询末尾时按右方向键即可
Shift 加上下键在剪贴板标签里扩展多选,Ctrl+Delete 删掉选中的,Shift+Enter 粘回上一个应用
Esc 依次清除图片查询、关闭设置、收起窗口
拖入、粘贴或点选一张图,进入以图搜图

预览面板按类型给不同的东西:文本文件给正文并高亮查询词,图片给原尺寸,视频给分镜条,仓库给 README 开头,剪贴条给全文。开出去之前先确认一眼,省得开错。

面板位于屏幕上方居中,置顶于所有窗口,失焦不隐藏,否则拖放无法完成;拖标签条可移动位置。多屏的话,唤出时出现在鼠标所在那块屏上。标签顺序和启动时打开哪个标签都能在设置里定,用不上的标签可以整个隐藏。空查询时本地源列出最近的文件,星标源列出最近标记的仓库,剪贴板源列出最近复制的内容。

界面有中英两种语言,跟随系统也可以在设置里指定,托盘菜单一并跟着切。自动更新是签名校验过的原地升级,启动时查一次,常驻期间每 24 小时查一次,有新版就在托盘图标上点一个红点,装不装还是你按。设置可以一键导出导入,除了 GitHub token 之外全带走。另外有一份本地活动日志,报 bug 的时候用,设置里有个按钮直接开日志目录,查询内容不写进去。

设置项:GitHub token 与连接状态、索引目录的增删与重建、外观(跟随系统、浅色、深色)、界面语言、拼音匹配开关、唤出快捷键、模型下载源、单文件大小上限、OCR 模型与开关、视频解码上限(线程数与硬件解码)、标签顺序与默认标签、剪贴板历史的开关与上限、设置导出导入、打开日志目录、模型下载进度、版本号。

限制

OCR 只对索引里的图片生效,扫描件 PDF 抽不出文字的仍然只能按文件名找到。
视频只认画面,不认声音,画面里的字也不过 OCR,一段没有视觉特征的录屏基本搜不到。
macOS 的包未签名也未公证,安装需多一步。
剪贴板的保密标记在 Linux 上无效,那边没有跨桌面环境的通行约定。
向量为暴力比对,几万块无压力,堆到几十万需换检索结构。
Twitter 的喜欢只能走数据导出的归档文件,官方 API 现已收费。

代码是 MIT,Rust 核心在 core/,Tauri 壳负责命令、托盘、全局热键、窗口、剪贴板监听和自动更新,前端只有一个窗口。起因是星标列表只能按名字搜,而人记得住的通常是「那个用 Rust 写的、干什么用的东西」,偏偏想不起来叫什么。后来发现别的几样东西也是同一个毛病:书签、历史、剪贴过的片段、装过的应用,都是存下来容易、想起来困难,索性一并收进同一个面板。