用 candle 算语义相关文章
主线位置:推理主线的另一种用法。没有 GGUF、没有 KV 缓存、不取样:
BertModel::forward一次吃进整段(最多 512 个 token),12 层 encoder 各做 4 次Linear、2 次批量matmul、一次 softmax、一次 GELU、两次 LayerNorm,返回最后一层的隐藏状态,再由调用方做 mean pooling。落到算子主线上,绝大多数matmul在 CPU 上走 gemm 或 Accelerate,在 Metal 上走 MLX gemm 内核。
flowchart TB files["读 705 个 index.md"] --> clean["去代码块 小写 去反引号"] clean --> tok["tokenizer 分字"] tok --> cut["截成 510 个 token"] cut --> fwd["BertModel forward"] fwd --> pool["mean pooling"] pool --> cos["余弦 前 5"] tfidf["burn 卷的 TF-IDF"] --> cmp["比较 前 5"] cos --> cmp
本站(yuxuetr/rustineverything)不用 candle。burn 卷的 用 burn 算相关文章 给每个文档页按 TF-IDF 余弦相似度挑了前 5 篇,结论是推荐大多合理,但 88.4% 落在同一卷里,705 页里有 530 页一条跨卷的都没有;它把「跨卷推荐」留给了语义向量。这一页就做这件事:用 candle 跑 bert-base-chinese,给同一批页面算句向量,和 TF-IDF 的结果逐页比较,再量代价。站点代码读的仍是提交 8729cfb,只读;站点怎么读文档、怎么部署,burn 那页已经讲过,这里只引用结论。
数据和 burn 那页完全相同。 那页用的是私有内容仓库在提交 dd09e82 时的 705 个 index.md。之后内容仓库只新增了页面(git diff --name-status dd09e82 HEAD -- docs 全是 A),没有改过旧页面,所以这里在原处读当前目录,再用 dd09e82 时的 705 个路径过滤,文字逐字相同。实验 crate exp-candle/site-bert 原样复制了 burn 实验的 text.rs(按 walk_docs 的规则取页、分词、计数)和 plain.rs(稀疏 TF-IDF),在同一进程里重算基线:
tfidf: vocab=52120 locality: same volume 3115/3525 (88.4%), same part 1150/3525 (32.6%), pages with no cross-volume hit 530/705
和 burn 页的数字一位不差。下文所有比较都以这一行为基线。全部是 release 构建,平台 M2 Max、macOS 27、rustc 1.99.0;机器上同时有别的实验在跑,每组计时都给了开始时的 load average。页面上只给标题和统计,不贴正文。
本站的条件
burn 那页读过的部署事实,这里用得上三条:
- 镜像在 CI 里构建,只出
linux/amd64,运行阶段是debian:trixie-slim,没有 GPU。 - 内容仓库在宿主上只读挂进容器(
:ro),更新文章只git pull,不重建镜像。 app容器mem_limit: 768m。
TF-IDF 没有模型文件;BERT 有,而且不小。这三条决定了下面的代价里哪些是硬约束。
模型和加载
bert-base-chinese 的 model.safetensors 是 411 553 788 字节(392.5 MiB),206 个张量全是 F32;config.json 的 architectures 是 BertForMaskedLM,文件里带着预训练用的两个头。candle 的 BertModel 只读 encoder 部分,没有 pooler,返回 (B, T, 768)。
这个模型不能直接交给 BertModel::load:它的 LayerNorm 参数还叫 gamma / beta,而 candle_nn::layer_norm 要 weight / bias;报出来的错误还指错了地方。原因和修法(VarBuilder 的 rename_f)见 BERT。这里用的就是那个改名:
// bert-base-chinese still uses the TF names gamma / beta for LayerNorm
let vb = vb.rename_f(|name: &str| {
name.replace("LayerNorm.weight", "LayerNorm.gamma").replace("LayerNorm.bias", "LayerNorm.beta")
});
不改名时的两条报错,本实验也复现了:
load: cannot find tensor embeddings.word_embeddings.weight
load pp(bert): cannot find tensor bert.embeddings.LayerNorm.weight
分词:两处 [UNK]
candle 不管分词,这一步是 tokenizers crate 读模型自带的 tokenizer.json。它的 normalizer 是 BertNormalizer,lowercase: false(tokenizer_config.json 也写着 "do_lower_case": false);而 vocab.txt 的 21 128 个词里,没有一个以大写字母开头。BERT 的 WordPiece 拆不出一个词时,整个词变成 [UNK]。所以按原样用,Router、Option、macOS 这类词全是 [UNK]。另一个大户是行内代码的反引号,它不在词表里。
统计全部 705 页(raw 是 TF-IDF 用的同一份文字;clean 是先转小写、删掉反引号;prose 再删掉围栏代码块,包括 mermaid):
raw: chars=8956836 tokens=4496787 unk=509587 (11.33%) per page median=6008 p10=670 p90=12196 max=26491 pages<=510: 45 chunks(510)=9175
raw+clean: chars=8956836 tokens=4451226 unk=928 (0.02%) per page median=5945 p10=646 p90=12171 max=26425 pages<=510: 47 chunks(510)=9081
prose+clean: chars=4282250 tokens=2460176 unk=586 (0.02%) per page median=3433 p10=496 p90=6361 max=11963 pages<=510: 73 chunks(510)=5174
most common [UNK] sources (prose, as shipped): [("`", 253392), ("T", 1167), ("None", 977), ("macOS", 959), ("PR", 800), ("Max", 796), ("Vec", 795), ("M2", 792), ("HTTP", 776), ("Option", 721), ("Router", 680), ...]
原样分词时 11.33% 的 token 是 [UNK];小写加去反引号之后只剩 0.02%。下文的输入一律先做这两步。英文词在这个词表里本来就切得很碎(tensor → te ##ns ##or),模型对标识符的理解有限;区分页面主要靠中文。中文每个字一个 token,所以 token 数和汉字数差不多。
tokenizer.json 原样(不转小写)编码 Tensor tensor Device::new_metal `matmul` ,输出里有几个 [UNK]?截断:只有 512 个位置
config.json 的 max_position_embeddings 是 512。BertEmbeddings::forward 用 0..seq_len 去位置嵌入表里 index_select,超出就报错,不会替你截断:
len 512: ok [1, 512, 768]
len 513: index-select invalid index 512 with dim size 512
去掉 [CLS]、[SEP],每段最多 510 个 token。而去掉代码块之后,页面的 token 数中位数是 3433,705 页里只有 73 页放得下;取开头 510 个 token,平均只覆盖一页的 29.5%。比较了三种取法:
- head:TF-IDF 用的那份文字(含代码块)的前 510 个 token。每页开头是标题、「主线位置」,接着就是一张 mermaid 图,图的源码会占掉一部分名额。
- prose:先删掉围栏代码块,再取前 510 个 token。大致是标题、主线位置和第一两段正文,也就是这一页对自己的概括。
- chunks:把 prose 全文按 510 个 token 切段(每段前面都拼上标题),每段算一个向量,按 token 数加权平均成整页的向量。705 页切出 5223 段,计算量是 prose 的 7.4 倍。
mean pooling
forward 返回每个位置的隐藏状态,句向量取有效位置的平均:padding 的位置用 mask 乘成 0,再除以有效长度。批里长短不一时 mask 必须传进 forward,原因见 BERT。
let hidden = model.forward(&ids, &types, Some(&mask))?; // (b, len, 768)
let m = mask.to_dtype(DType::F32)?.unsqueeze(2)?; // (b, len, 1)
let sum = hidden.broadcast_mul(&m)?.sum(1)?; // (b, 768)
let n = m.sum(1)?; // (b, 1)
Ok(sum.broadcast_div(&n)?)
705 个向量各做 L2 归一化,两两点积就是余弦相似度,每页取前 5(不含自己)。705 × 705 × 768 次乘加,纯 Rust 单线程约 0.3 s,和 TF-IDF 那一步是同一个量级。
结果:和 TF-IDF 比
同一组 705 页、同样的统计口径(「同卷」「同部分」「没有跨卷推荐的页」与 burn 页相同)。「与 TF-IDF 重合」是两边前 5 的交集占 3525 条推荐的比例:
| 做法 | 同卷 | 同部分 | 没有跨卷推荐的页 | 与 TF-IDF 重合 | 第 1 名相同 | 全部两两余弦平均(最小 – 最大) |
|---|---|---|---|---|---|---|
| TF-IDF(burn 页) | 88.4% | 32.6% | 530 | — | — | — |
| BERT head | 59.3% | 22.9% | 146 | 25.8% | 128 | 0.969(0.856 – 0.994) |
| BERT prose | 63.9% | 28.1% | 202 | 28.5% | 131 | 0.970(0.852 – 0.993) |
| BERT chunks | 75.9% | 21.0% | 327 | 48.3% | 315 | 0.981(0.887 – 0.998) |
跨卷推荐确实多了,但「多」本身说明不了好坏。看推荐落到哪里,再加一项统计:跨卷推荐里,有多少落在别卷的同一种部分(坑对坑、「在本站中」对「在本站中」);坑页面(170 页、850 条推荐)有多少条指向别卷的坑页面:
tfidf: cross-volume hits in the same kind of part: 300/410; pitfall pages: 41/850 of their hits are other volumes' pitfall pages
prose: cross-volume hits in the same kind of part: 644/1272; pitfall pages: 202/850 of their hits are other volumes' pitfall pages
chunks: cross-volume hits in the same kind of part: 340/851; pitfall pages: 55/850 of their hits are other volumes' pitfall pages
prose 的 1272 条跨卷推荐里,坑页面互相推荐占了一大块:坑页面的标题是一句报错原文,开头一段是「报错是什么、什么时候出现」,BERT 看到的是这种文体。chunks 读了全文,跨卷推荐少了一半,坑对坑也回到和 TF-IDF 差不多的水平。
和 burn 页同样的四页,加上一页 tokio(= 表示 TF-IDF 的前 5 里也有它)。prose:
[burn/03-backend/flex] Flex:CPU 后端
0.986 Autodiff 装饰器 (burn/04-autodiff/autodiff-backend)
0.986 张量 (burn/02-tensor)
0.985 优化器 (burn/07-module/optimizer)
0.985 Device (burn/02-tensor/device)
= 0.985 后端与分派 (burn/03-backend)
[serde/07-pitfalls/missing-field] missing field `x`
0.989 data did not match any variant of untagged enum Shape (serde/07-pitfalls/untagged-no-match)
0.988 unexpected null; try decoding as an `Option` (sqlx/08-pitfalls/unexpected-null)
0.988 Missing value for column 'body' (sea-orm/09-pitfalls/missing-value-for-column)
= 0.987 错误 (serde/02-core/errors)
0.986 key must be a string (serde/07-pitfalls/key-must-be-a-string)
[tokio/03-task/poll-and-wake] poll 与唤醒
= 0.989 JoinHandle、取消与 JoinError (tokio/03-task/join-handle)
0.987 service overloaded (tower/09-pitfalls/service-overloaded)
0.986 AlreadyBorrowed / AlreadyBorrowedMut (dioxus/09-pitfalls/already-borrowed)
0.985 阻塞线程池 (tokio/04-scheduler/blocking-pool)
0.985 LoadShed (tower/04-middleware/load-shed)
chunks:
[axum/02-handler/state] 状态:State 与 FromRef
0.995 Handler trait (axum/02-handler/handler-trait)
= 0.995 一个请求穿过 axum (axum/01-overview/request-through-axum)
= 0.994 expected `Router`, found `Router<AppState>` (axum/10-pitfalls/router-state-mismatch)
0.994 状态 (tauri/04-app/state)
0.994 from_fn 与它的兄弟 (axum/05-middleware/from-fn)
[sqlx/04-pool/acquire] 获取连接
= 0.997 归还、Drop 与维护任务 (sqlx/04-pool/release-and-maintenance)
= 0.996 pool timed out while waiting for an open connection (sqlx/08-pitfalls/pool-timed-out)
0.996 连接池 (hyper/06-client/pool)
= 0.996 PoolOptions 与默认值 (sqlx/04-pool/pool-options)
0.995 超时取消 pool.begin():连接带着事务回到池里 (sqlx/08-pitfalls/cancelled-begin)
[tokio/03-task/poll-and-wake] poll 与唤醒
= 0.997 JoinHandle、取消与 JoinError (tokio/03-task/join-handle)
0.995 JoinSet、LocalSet、task_local (tokio/08-api/joinset-localset)
0.995 wake() 了,poll 却等到超时 (mio/06-pitfalls/waker-dropped-before-poll)
0.995 poll(2) 后备 (mio/03-platforms/poll-fallback)
= 0.995 OwnedTasks 与关闭 (tokio/03-task/owned-tasks)
- prose 的跨卷推荐有对的,也有错的。
missing field推到 sqlx 的「unexpected null」和 sea-orm 的「Missing value for column」,三者都是「数据里缺了一个值」,这正是 TF-IDF 给不了的关系;但poll 与唤醒推到 dioxus 的借用冲突和 tower 的过载,只是同为坑页面或同为「讲机制」的文体相近。Flex 一页五条都在 burn 卷,但「优化器」「Autodiff 装饰器」和 CPU 后端关系较远,TF-IDF 给的burn-cubecl后端、Dispatch更贴近。 - chunks 的跨卷推荐少,但读起来更像人会给的:axum 的
State推到 tauri 的「状态」,sqlx 的连接池推到 hyper 的连接池,tokio 的poll推到 mio 的「wake() 了,poll 却等到超时」。这些都是 burn 页说「要靠语义向量」的那类关系。代价是它和 TF-IDF 有一半重合,同卷比例回到 75.9%。 - 换设备、换实现几乎不改变结果:prose 在 Metal 和 CPU(开不开
accelerate)上算出的前 5 在 705 页上完全相同;下文的两处提速(融合 softmax、tanh 近似 GELU)只让 9 页的前 5 换了一篇,第 1 名全部不变。
问: 所有页面两两余弦的平均是 0.970,第 5 名和第 1 名只差 0.001 到 0.005。分数这么挤,前 5 还靠得住吗?
答: 分数挤是 BERT 向量的通病:所有向量共享一个很大的公共方向,余弦都偏高(BERT 里不相关的句子也有 0.63 到 0.72)。排序只看相对大小,挤本身不要紧,要紧的是排序稳不稳。把 705 个向量各减去它们的平均向量再归一化,分数散开到 -0.55 到 0.87,同卷比例几乎不变(prose 63.9% → 64.5%),但前 5 只有 63.8% 和原来相同,第 1 名只有 398 页不变。也就是说,前 5 里有相当一部分是在几乎一样的分数之间排的序,换个合理的后处理就会换人。bert-base-chinese 只做过掩码语言模型预训练(文件里是 MLM 和下一句预测两个头),没有为句子相似度微调过,这一点在分数上看得出来。
代价
时间
prose 一组 705 段、347 939 个 token,批大小 8,按长度排序后分批;计时从第一批开始到最后一批读回,Metal 在结束处 synchronize()。Metal 的库编译缓存是热的(冷缓存时的第一批在本实验的第一次运行里是 10.5 s,机制见 Metal 后端)。
| 设备与构建 | 原样(candle-transformers 的 BertModel) | 融合 softmax + tanh GELU | 开始时 load |
|---|---|---|---|
| Metal | 70.1 s(99.5 ms/段) | 42.7 s(60.6 ms/段) | 13.1 / 9.0 |
CPU,accelerate | 268.3 s(380.5 ms/段) | 88.3 s(125.2 ms/段) | 6.4 / 32.6 |
CPU,不开 accelerate(gemm) | 381.0 s(540.5 ms/段) | 257.7 s(365.6 ms/段) | 14.1 / 5.4 |
chunks 一组 5223 段、2 505 628 个 token,只在 Metal 上用提速版跑了一次:307.7 s(58.9 ms/段)。按 prose 的每段时间推算,同样的工作量 CPU 开 accelerate 约 11 分钟,不开约 32 分钟(推算,没有跑)。作为对照,TF-IDF 全站分词加计算约 0.3 s。
「融合 softmax + tanh GELU」是本实验对 BertModel 的两处改动,各自的效果用一批 8 × 512 的固定输入分开量(每格是第 2 到 4 次的平均,ms/段):
| 原样 | 只换 softmax | 只换 GELU | 两者都换 | |
|---|---|---|---|---|
| Metal | 103.6 | 63.0 | 104.1 | 62.5 |
CPU,accelerate | 420.9 | 364.4 | 213.2 | 144.9 |
- GELU:
config.json的"gelu"对应精确的gelu_erf,它在 CPU 上逐元素调libm::erff、单线程;tanh 近似的gelu开了accelerate时整段交给 vForce。这一点 BERT 已经量过(CPU 上占一半时间)。在 512 个 token 的长输入上同样如此:CPU 快一倍,Metal 上没有差别。不开accelerate时 tanh 近似也是标量循环,所以 gemm 那一行只快 1.5 倍。 - softmax:
BertSelfAttention::forward用的是拼出来的ops::softmax,不是融合的softmax_last_dim。短句子上这一步不显眼;512 个 token 时每层的注意力分数是(8, 12, 512, 512),2500 万个元素要过五个 op(max_keepdim、broadcast_sub、exp、sum_keepdim、broadcast_div)。单独量这一个张量,Metal 上拼出来的版本 69.6 ms、融合版 4.3 ms;放回整个模型里,Metal 每段从 103.6 ms 降到 63.0 ms。改法是把bert.rs复制进实验 crate,只改这一行。融合版不记反向(见常用层),对只做推理的这里没有影响。
softmax_last_dim 出现后,换过去的是 stable-diffusion 和 whisper,不是 BERT#744(「Add a custom softmax implementation.」,2023-09-05)在 candle-nn 里加了 softmax_last_dim:只对最后一维、CPU 上按行并行的自定义算子。提交说明里写着「Use the slow implementation on cuda.」「Add a todo for the cuda kernel.」,同一个提交就把 stable-diffusion 例子的注意力从 softmax(&xs, D::Minus1) 换了过去;六天后 #810(「Use softmax-last-dim in whisper.」)换了 whisper。两者都在 0.2.2 发布(本地仓库最早的 tag 是 0.5.1,这个版本号是按版本号提交 #822 推断的)。bert.rs 里注意力的 softmax 从 2023 年 7 月写下以后,含 softmax 的改动只有加 tracing 和搬进 candle-transformers 两次,到 0.11.0 一直是拼出来的 ops::softmax。
内存
| 运行 | 最大 RSS | peak memory footprint |
|---|---|---|
| Metal,批 8 | 826 MiB | 1376 MiB(原样)/ 1029 MiB(提速) |
CPU accelerate,批 8,原样 / 提速 | 1416 / 1146 MiB | 1395 / 1122 MiB |
| CPU gemm,批 8,原样 / 提速 | 1404 / 1027 MiB | 1388 / 1012 MiB |
CPU accelerate,批 1,提速 | 819 MiB | 532 MiB |
模型本身就是两份:from_mmaped_safetensors 把 392.5 MiB 的文件映射进来,取张量时再复制一份(safetensors 与 mmap)。批 1 时 RSS 819 MiB 里,约 392 MiB 是映射的文件页,「peak memory footprint」不算干净的文件页,是 532 MiB。批 8 时每层的注意力分数就有 8 × 12 × 512 × 512 × 4 字节 = 96 MiB,拼出来的 softmax 还要多几份同样大的中间结果,所以原样版本超过 1.3 GiB。对照容器的 768 MiB 上限:批 1 也只剩两百多 MiB 给站点本身,批 8 直接超限。TF-IDF 版的进程峰值是 61.6 MiB(burn 页)。
体积
| 构建(release,本实验的二进制) | 大小 | strip 后 |
|---|---|---|
| 不开 feature(gemm) | 6.47 MiB | 5.05 MiB |
accelerate | 6.26 MiB | 4.87 MiB |
metal | 7.41 MiB | 5.91 MiB |
metal + accelerate | 7.20 MiB | 5.74 MiB |
二进制里包括 tokenizers(带 onig 正则库)和 candle 三个 crate。比 burn 只开 flex 的 8.8 MiB 还小一些,比纯 Rust 的 TF-IDF(0.53 MiB)大一个数量级;但真正的大头是模型:392.5 MiB 的权重加 263 KiB 的 tokenizer.json,要跟着二进制一起到服务器上。
在 Linux 服务器上(只读了源码)
服务器是 x86_64 Linux、没有 GPU,镜像不开任何 feature 时 candle 的 CPU matmul 走 gemm crate:它在运行时检测 CPU,有 FMA 就用 AVX2 + FMA 的内核,AVX-512 内核要 gemm 的 x86-v4 feature,candle 的工作区依赖没有开。不开 mkl 时,Gelu 和 GeluErf 都没有向量化版本,erf 是 libm。所以上表「不开 accelerate」一行是最接近服务器的配置:同样是 gemm、同样是标量激活。绝对时间取决于服务器的核数和内存带宽,这些没有测;但「全站一次要几分钟、chunks 要半小时级别、模型两份占掉大半个容器」这几条只取决于模型和数据的大小。
结论
在本站的条件下,BERT 不值得替换 TF-IDF。
- 效果:prose 把同卷比例从 88.4% 降到 63.9%,但多出来的跨卷推荐一部分是按文体(坑对坑)配的对,不是按内容;前 5 和 TF-IDF 只重合 28.5%,又对后处理敏感。chunks 的跨卷推荐质量明显更好,但跨卷的页只比 TF-IDF 多 200 页左右,前 5 有一半和 TF-IDF 相同。bert-base-chinese 不是句向量模型,这是上限所在。
- 代价:TF-IDF 全站 0.3 s、几十 MiB、没有模型文件;BERT 要 392.5 MiB 的模型,进程 800 MiB 起,CPU 上 prose 几分钟、chunks 半小时级别。站点已有的 tantivy 索引还有
MoreLikeThisQuery可用(见 burn 页),零新依赖。
如果要用,放在内容仓库一侧离线算。
- 离线(推荐):在写作用的 Mac 上跑 Metal,chunks 全站约 5 分钟,prose 不到 1 分钟;输出一个「页面路径 → 5 个相关页面路径」的 JSON,本实验写出来是 131 KB,跟着内容一起提交、
git pull。服务器上零计算、零模型,只读一个文件;内容是只读挂载,读不受影响。代价是写作流程多一步,忘了跑就过期,新页面在下一次生成前没有推荐。 - 进程启动时:要把 392.5 MiB 的模型放进镜像或内容仓库,每次启动在 CPU 上算几分钟到半小时,内存逼近或超过 768 MiB。不合适。
- 每次请求时:同样要常驻模型,而且一页要和全站比,至少要先有全站的向量。不合适。
- 构建镜像时:镜像里没有内容,做不到(burn 页已说明)。
什么时候重估:换成一个为句子相似度训练过的中文模型(结构仍是 BERT 时,candle 的 BertModel 可以直接加载),并且它在上面同样的统计里,跨卷推荐增加的同时坑对坑的比例不升高。本地没有这样的模型,没有测。
动脑筋答案
as shipped: "Tensor tensor Device::new_metal `matmul`" -> ["[UNK]", "te", "##ns", "##or", "[UNK]", ":", ":", "new", "_", "metal", "[UNK]", "ma", "##tm", "##ul", "[UNK]"]
lowercased, no backticks: -> ["te", "##ns", "##or", "te", "##ns", "##or", "de", "##vice", ":", ":", "new", "_", "metal", "ma", "##tm", "##ul"]
4 个:Tensor、Device 各一个(首字母大写,词表里没有大写的片段,整个词变成 [UNK]),两个反引号各一个。new_metal 没事:预分词先在标点(:、_)处切开,new、metal 都是小写。转小写、去掉反引号之后一个 [UNK] 都没有,Tensor 和 tensor 变成同样的三个片段。
小结
- 数据与 burn 卷相同:内容仓库
dd09e82时的 705 页,原样复用它的分词和 TF-IDF,基线 88.4% 同卷、530 页无跨卷推荐,在同一进程里复现。 - bert-base-chinese 要改名才能加载(见 BERT);原样的 tokenizer 把含大写字母的词和反引号变成
[UNK](11.33%),转小写、去反引号后 0.02%;位置只有 512 个,超出直接报错。 - 每页平均 3433 个 token,开头 510 个只覆盖 29.5%。只看开头(prose)同卷 63.9%,但多出的跨卷推荐不少是坑对坑的文体匹配;全文分段平均(chunks)同卷 75.9%,跨卷推荐更像人给的。余弦全挤在 0.97 上下,前 5 对后处理敏感。
- Metal 全站 prose 42.7 s、chunks 308 s;CPU 开
accelerate88 s,不开 258 s(提速版)。两个瓶颈:bert.rs的拼接 softmax(Metal 上换成softmax_last_dim快 1.6 倍)和标量gelu_erf(CPU 上换 tanh 近似快一倍);两处改动只让 9 页的前 5 换了一篇。 - 模型 392.5 MiB、进程 RSS 819 MiB 起,超过容器的 768 MiB;TF-IDF 是 0.3 s、62 MiB。结论:不替换 TF-IDF;真要用,在内容仓库一侧用 Metal 离线算出 131 KB 的 JSON 随内容发布。