我想给自己博客上的 32 篇旧文做个「相关段落」检索——输入一句话,返回最相关的几段。这件事的第一步是给每段文本弄个向量,第二步是把这些向量存下来做相似度搜索。

第一步我先卡住了:手上没有能直接用的 embedding API key,也不想让一个检索脚本依赖网络。第二步更烦:我算了一下,如果真按 float32 存,光向量就是 条数 × 维度 × 4 字节,一千条 1024 维就是 4MB,一百万条就是 3.81GB。这还只是向量本身,不算索引。

所以我干脆把「向量怎么存」这件事单独拎出来测了一遍。下面所有数字都是我本地跑出来的,没有一个是抄来的。

先解决没有模型怎么办:我用随机索引造向量

不接 API 又想要稠密向量,我用的是 Random Indexing:每个词随机分配一个稀疏的「索引向量」(512 维里随机挑 8 个位置填 ±1),一段文本的向量就是它所有词的索引向量加权求和。再多做一轮 Reflective RI(词向量 = 包含它的段落向量之和,段落向量再用词向量重算一遍),就能拿到一点二阶相似性。

它的好处是完全确定性、零依赖、可复现,缺点是抓的是字面共现,算不上真语义。但我要测的是量化会不会把排序搞坏,这个结论跟向量是谁产出的关系不大——我后面专门换了另一种分布的向量复测,就是为了防止自己被这组数据骗了。

语料切成 288 段,词表 10682,维度 512。抽查时我让它查「Windows Server 2022 搭建 AD 域控」,返回的第一条是「一台 ESXi 搞定 AD + DNS + DHCP + GPO 实验域」——不是同一篇,但话题是对的,说明这套向量至少能用。

第一次量:int8 几乎无损,1bit 掉得厉害

金标准我用 float32 精确内积的排序,查询是 32 篇的标题各一条,看量化后的 top-k 能捞回金标准的多少。

float32(基线)        recall@1 100.0%   recall@10 100.0%   recall@50 100.0%   576 KB
int8 per-vector scale  recall@1 100.0%   recall@10 100.0%   recall@50  99.7%   145 KB
int8 global scale      recall@1 100.0%   recall@10  99.4%   recall@50  99.7%   144 KB
binary sign(1bit)     recall@1  78.1%   recall@10  76.9%   recall@50  71.5%    18 KB

int8 基本白捡:内存砍到 25.2%,recall@10 一点没掉,recall@50 只掉了 0.3%。per-vector 比 global 好一点点,我本来以为差别会更大,查了一下 scale 分布才明白为什么——我这些向量的 per-vector 最大绝对值,中位数是 0.1547,最大 0.2201,比值只有 1.42。也就是说用全局 scale 时,中位数向量只用到 89/127 ≈ 70% 的分辨率,还没到崩的程度。如果你的语料里有 outlier 向量(比如超长文档),这个比值会大得多,global scale 会明显吃亏。

1bit 就没这么好看了:内存只剩 3.1%,recall@10 掉到 76.9%。这个数字我先记下来,后面发现它特别不可信。

1bit 的正确用法是粗排,不是直接出结果

76.9% 不是不能用,但我想知道能不能救回来。做法很土:先用二进制汉明距离捞一批候选,再用原始向量(或 int8)给这批候选重新打分。

const cand = searchBinary(binaryIndex, binarize(query), C);  // 粗排
const refined = cand.map(i => [i, dot(queryFloat, V[i])])     // 精排
                    .sort((a, b) => b[1] - a[1]);
C=5    → float32 精排   recall@10  45.0%   (候选只有 5 个,上限就是 50%,被候选数卡住)
C=10   → float32 精排   recall@10  76.9%
C=20   → float32 精排   recall@10  92.8%
C=50   → float32 精排   recall@10  98.8%   精排占比 17.4%
C=100  → float32 精排   recall@10 100.0%   精排占比 34.7%
--- 精排改用 int8,不再需要原始 float32 ---
C=20/50/100 → 92.8% / 98.8% / 100.0%

第二组是我最想要的结论:精排换成 int8,召回一模一样。这意味着我可以只存「1bit 粗排索引 + int8 原始向量」,完全不用保留 float32,粗排时只扫 1/32 的数据量,只对 17%~35% 的候选做精确计算。

换一组向量,1bit 从 76.9% 变成 23.3%

上面那组数字我看完有点得意,直到我把向量换成另一组:4000 条合成的、按簇分布、带各向异性(前几维方差大、后面递减)的向量,更接近真实模型 embedding 的形状。

float32            recall@10 100.0%
int8 per-vector     recall@10  99.0%
int8 global         recall@10  98.0%
binary sign(1bit)  recall@10  23.3%
binary 粗排 C=20 → 37.0%   C=50 → 66.3%   C=100 → 100.0%

int8 依然稳(99.0%),但 1bit 从 76.9% 崩到 23.3%,而且粗排要 C=100(2.5% 的库)才救得回来。同一套代码、同样的量化方式,只换向量分布,结论差了三倍多。所以任何「二进制量化召回率 XX%」的数字,脱离了向量来源就是废纸——包括我上面那 76.9%。

我想找出原因,只验证了一半

我的第一个猜测是:量化损失取决于候选之间挨得多近,也就是第 10 名和第 11 名的相似度间距。两组数据看起来是支持的——真实语料间距 0.0153、合成向量 0.0032,差 5 倍。

于是我做了个对照:给真实语料的每段加几个带噪声的副本,人为把间距压下去。第一版我加的噪声是 0.05,结果副本和原向量的余弦只有 0.8 左右——我造的「近邻副本」根本不近,等于白做。把噪声降到 0.0005,余弦到 1.000,间距压到 0.0001,结果:

基线                        binary 76.9%   int8 100.0%
副本余弦 1.000 / 间距 0.0001  binary 73.4%   int8  89.4%

int8 确实从 100% 掉到 89.4%,假设对它成立。但 binary 只从 76.9% 掉到 73.4%,没崩。我盯着这个结果看了一会儿才反应过来:加进去的副本本身也进了金标准集合,等于送分题,这个对照实验从设计上就证伪不了 binary。

所以这条我只敢写一半:int8 的损失随候选变挤而上升,这条我验证了;1bit 为什么会从 76.9% 掉到 23.3%,我没找到让自己信服的解释,两个可能的因素(分布各向异性、候选间距)我都测了,都没能单独复现。

顺手记一个反直觉的数字:我用参与比(PR)量了两组向量的各向异性,真实语料 153/512、合成向量 43/512,差得很远;但 cos(sign(v), v)——单个向量被二值化后保留了多少方向信息——两组几乎一样(0.785 vs 0.790)。单个向量压得好不好,跟检索排得好不好,是两件事。

速度:int8 在 Node 里是零收益

我把语料复制 40 份加抖动凑成 11520 条,专门测全库扫描吞吐(20 次平均):

float32 暴力扫描   19.18 ms/次    601 k 向量/秒
int8 暴力扫描      19.28 ms/次    598 k 向量/秒
binary 汉明扫描     4.49 ms/次   2566 k 向量/秒

int8 和 float32 完全一样快,甚至略慢。原因很直白:JS 里数字都是 double,Int8Array 取出来照样要转成 double 再做乘法,省下的内存带宽在 JS 这一层体现不出来。int8 的收益是内存,不是速度——真正的加速要靠 SIMD(Qdrant 文档里明确写了它用 8-bit 的 SIMD 指令)。binary 能快 4.3 倍,是因为位运算真省事:

function popcount32(x) {
  x = x - ((x >>> 1) & 0x55555555);
  x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
  x = (x + (x >>> 4)) & 0x0f0f0f0f;
  return (x * 0x01010101) >>> 24;
}

顺便试了「砍维度」,比量化差远了

还有一条省内存的路是不量化、直接截短向量。我的 512 维是随机索引造的,各维等价,所以截断等价于随机降维(真实模型的 MRL 是训练出来的,前 k 维特意保留了主要信息,会比我这个好):

k=512              recall@1 100.0%   recall@10 100.0%   内存 100%
k=256              recall@1  87.5%   recall@10  85.3%   内存 50%
k=128              recall@1  81.3%   recall@10  77.2%   内存 25%
k=64               recall@1  75.0%   recall@10  62.2%   内存 12.5%
k=256 + int8       recall@10  85.0%                     内存 12.5%

砍一半维度掉到 85.3%,而 int8 同样省一半以上(25%)却几乎无损。结论很明确:先量化,别乱砍维度,除非模型明确支持 MRL 截断。

跟产品文档对一下账(核对日期 2026-10-15)

我跑完去看了一眼主流产品的说法,跟我的数基本对得上:

  • pgvector(GitHub README,0.8.7,版本 bump 2026-10-01):binary_quantize(vector) → bit 是 0.7.0 加的,bit 类型上限 64000 维,汉明距离用 <~>。它 Scaling 那一节直接写了「Use binary quantization for indexes (with re-ranking for search)」——和我实验二的结论一致:二进制必须配重排。halfvec 上限 4000 维,HNSW 默认 m=16、ef_construction=64。
  • Qdrant(文档,binary quantization 自 v1.5.0):1bit 是 32× 内存压缩、最高 40× 加速,按 0 取符号,要求分量分布居中。它明确写了「vectors smaller than a thousand dimensions 精度显著下降,通常需要昂贵的 rescoring」——我的 512 维正好踩在这个坑里,这大概也是我那 76.9% 好看不了的原因。它的官方基准是 ada-002(1536 维)用 4× oversampling 达到 0.98 recall@100。int8 标量量化是 4× 压缩、误差通常 <1%。
  • OpenAI dimensions 参数:text-embedding-3-small 默认 1536 维、large 3072 维,支持传 dimensions 用 MRL 截短。⚠️ 这条我要说明:官方 platform.openai.com 被 Cloudflare 拦了,我读的是其中文镜像文档,另有几家二手来源口径一致;我没有真实调用验证过。

我的判断

  • int8(per-vector scale)是默认值级别的选择:4 倍内存、几乎零召回损失、实现只要十几行。别指望它加速,省内存就是它的全部价值。
  • 1bit 只在「粗排 + 精排」里用,而且必须自己测过再上:它在我的两组向量上差了三倍多。粗排候选取到库大小的 17%~35% 才安全,别照抄文档里的 oversampling 倍数。
  • 别拿单组数据下结论:我这轮最有价值的不是那几个召回数字,而是换一组向量之后它们全变了。
  • 我的实际做法:先存 int8(per-vector scale,另外存一个 float32 scale,512 维下只占 0.78%),真到了库大得放不下的那天,再叠一层 1bit 粗排索引,并且在上线前用我自己的语料重跑一遍 C 值曲线——跑不通就退回 int8 全库扫,反正 60 万向量/秒的暴力搜索在小库上够用了。

(附:上面这套脚本全是零依赖 Node,语料是我博客上的旧文。如果哪天我要接真 embedding 模型,我会先去硅基流动上试 BAAI/bge-m3——https://cloud.siliconflow.cn/i/pyV0lE45 ,这个链接含我的邀请码,走它注册我有返利。)

最后修改:2026 年 10 月 15 日
如果觉得我的文章对你有用,请随意赞赏