1. 为什么本地跑千万级 RAG 这么难
1.1 向量库的“大”到底大在哪
做 RAG 的应用基本都绕不开一个问题:embedding 一跑,向量库就越来越肥。很多人第一次算账时会愣住,一千万条常见规格的向量(1536 维,float32)光原始向量就要 1536 × 4 字节 ≈ 6.14KB,一千万条差不多 61.4GB。这个数量级有多夸张?一篇文章按 500 字切块,通常能产出几十到几百个 chunk,一个几十万篇文档的知识库,切出千万级 chunk 并不稀奇。于是文档还没索引完,磁盘先报警了。
但向量库不只有“原始向量”这一层大头。为了做相似度检索,你大概率会用 HNSW 之类的图索引。HNSW 会在每个向量周围维护若干条“邻居边”,常见配置 M=32 时,每条边至少要存目标向量 id(4字节)和一些状态位,双向图再加层级,一千万点大概会产生 5~7GB 的邻接表。再加上 doc_id、chunk_id、原文 offset、各种自定义字段,整套下来突破 70GB 是非常正常的。更难受的是,很多 RAG 框架为了查询快,会默认把向量索引整个塞进内存,16GB 的笔记本连一个零头都装不下。
这里有个容易忽略的点:向量库的体积并不只是“存储成本”,它直接决定了你能否在本地跑。本地跑 RAG,通常还挂着 embedding 模型和 7B~14B 的本地大模型,光模型权重就占 8~16GB。如果向量库还要额外吃 70GB 内存,那已经不是单一软件优化能解决的问题,而是整个架构选型就得改。
1.2 就算磁盘放得下,内存和 IO 也会卡死你
有人会说:“磁盘够大不就行了?”实际上没那么简单。RAG 查询时,系统需要先通过索引找到候选向量集合,再对候选做距离计算。HNSW 这类图结构本身就是随机访问密集型:每跳一个节点都要去读它的邻居表。如果索引全部在磁盘上,没有 mmap 或内存映射,一次查询可能触发几百次随机 IO,时延直接到几百毫秒甚至秒级。即便用 mmap,也受操作系统缺页中断拖累,高频检索时盘片一直转,CPU 也在空等。
内存带宽同样是瓶颈。假设你退化成暴力扫描,一千万条 6KB 向量全量扫一遍就是 60GB 数据传输量,单核内存带宽通常不到 20GB/s,即使按理论极限,一次全量距离计算也要秒级。HNSW 虽避免全表扫描,但候选集一旦扩大,仍然要吃大量带宽。压缩后向量变成每条约几十字节,同样一千万条数据只有不到 1GB,可以整块放进 CPU cache 或内存,距离计算的吞吐量完全不是一个量级。
这也是压缩方案最核心的价值之一:它不只是省磁盘,更是在把“数据从硬盘搬到 CPU”这个瓶颈整体往后挪。之前 64GB 的索引无法常驻内存,压缩到 2GB 后,哪怕同时跑模型和索引,16GB 机器也能稳定覆盖。
1.3 压扁 97% 到底意味着什么
先对“97%”做笔账。64GB 压缩 97%,剩下约 2GB;如果原始索引 70GB,那么剩下约 2.1GB。这个量级很重要:16GB 内存的本地机器,系统吃掉 3~4GB,embedding 模型 1~2GB,LLM 推理留 8GB 左右,剩下不到 4GB 给向量搜索引擎。2GB 的向量索引刚好挤得进去。
我在本地模拟了几组配置,结论是“97%”这个数字并不是极限喊话,而是一个相对均衡的档位。极端一点用子空间量化,可以把每个 6KB 的向量压到 32 字节,相当于压缩 192 倍(也就是 99.5%)。但压缩越大,召回精度损失越明显,所以项目一般会选一个“缩水但仍可用”的比例。压缩 97% 对应的量级,恰好是压缩到每向量 150~200 字节左右,再配上重排策略,能兼顾存储和效果。
2. 核心原理:量化、乘积量化与重排
2.1 标量量化:最简单的一层“压水”
先把最基础的量化讲清楚。原始 embedding 每个维度是一个 32 位浮点数,取值是任意实数。但我们真的需要那么精确吗?在很多检索场景里,两段文本的语义相似度主要取决于向量的大致方向和幅度,而不是每个维度的精确数值。于是可以先做标量量化(Scalar Quantization,简称 SQ):把每个维度的值映射到一个小整数区间,比如 int8 的 0~255。
打个比方,原来每个维度是一个精确到小数点后很多位的“百分制成绩”,现在改成“及格/中等/良好/优秀”四档,分数细节没了,但排序的相对位置还在。标量量化对单维误差的影响是可控的,通常会让召回率下降一两个点,但换来 4 倍压缩。代码上很直白:
import numpy as np vec = np.random.randn(1536).astype(np.float32) vmin, vmax = vec.min(), vec.max() scaled = (vec - vmin) / (vmax - vmin) * 255 vec_int8 = scaled.astype(np.uint8) # 还原近似向量 approx_vec = vmin + vec_int8 / 255.0 * (vmax - vmin)不过标量量化只能压到原来的四分之一,离 97% 还差得远。所以需要更强的压缩方式。
2.2 乘积量化:把高维空间拆成很多小格子
乘积量化(Product Quantization,简称 PQ)是向量压缩里更狠的一招。它的思路是把一个高维向量切成若干段子向量,比如 1536 维切成 32 段,每段 48 维;然后对每一段单独做聚类,每段训练出 256 个聚类中心。这样每个子向量不再保存 48 个浮点数,而是只保存“它属于第几个聚类中心”的下标。一个下标用一个字节表示,32 段合起来就是 32 字节。
原理类似于用一堆“模板零件”去拼装物体:你不需要记录每个螺丝的精确尺寸,只需要记录用了哪个标准型号。每个子空间的 256 个中心,相当于 256 种“标准零件”。原向量的 1536 维其实包含大量冗余,PQ 通过聚类把这些冗余信息剥掉,只保留最核心的“零件编号”。
按 32 字节计算,一千万条向量压缩后的体积大约 320MB。即使加上码本、图索引和辅助文件,仍然能控制在 2~3GB。这个压缩率足够支撑标题里的“97%”,和原始 60GB 相比完全是两个世界。如果压缩率还想要更激进,可以把子空间数量从 32 提高到 64,每个向量进一步降到 16 字节,体积再减一半;但召回精度会继续下滑,不建议默认使用。
2.3 如何在压缩的同时维持召回效果
只靠 PQ 压缩,检索时该怎么算距离?朴素的思路是:把查询向量也按同样方式切分,然后逐段计算查询子向量到该段 256 个聚类中心的距离,生成一张“距离表”。候选向量的每一小段只是一个中心下标,查表就能得到距离片段,累加所有片段就得到了整体近似距离。这样计算量和存储量都大幅下降。
不过距离是“近似”的,所以项目里普遍还会加一层重排(rerank)。先用压缩后的 PQ 索引做索引级召回,拿到比如 Top 100 个候选;然后对这些候选使用更精确的表示(比如原始向量或者更高精度的量化向量)重新排序,最终输出 Top 10。这有点像先看简历粗筛,再叫关键候选人到场面试。压缩负责降低存储和初筛成本,重排负责保住最终精度,两者配合才能让 97% 压缩不变成“花架子”。
在一些更精细的实现里,还有两个增强技巧。一个叫 OPQ(优化乘积量化),它会在量化前对向量做一个正交旋转,让切割的各子空间方差更均衡,从而让量化误差更均匀。另一个叫残差量化:先量化整体重心,再对残差做二次量化。VecTiny 这个项目默认开启 OPQ,我用下来发现,同样的码长情况下,OPQ 对召回率的改善非常稳定,通常能挽回 1~3 个百分点。
2.4 为什么不直接降维或者上哈希
有人会问:既然 1536 维太多冗余,为什么不先把维度降到 256 维再存储?PCA 降维确实能压缩,比如 1536 维 float32 降到 256 维,每向量从 6KB 降到 1KB,约压缩 6 倍,远达不到 97%。而且 PCA 对检索排序的破坏往往比 PQ 更直接,维度压太多,语义区分度会变差。LSH 局部敏感哈希则偏向等概率碰撞,它的哈希值随机性强,不太适合 RAG 这种需要稳定返回最接近结果的情况。PQ 的优势在于它是“学习式”压缩,每个子空间的聚类中心都从真实 embedding 分布里学出来,所以对目标数据分布特别友好。这也是 Vectiny 选择 PQ 作为核心压缩模块的原因。
3. 实操:在本地把一个千万级 RAG 压进 2GB
3.1 准备环境和数据
我测试用的机器是一台普通笔记本:16GB 内存、8核 CPU、512GB SSD。目标数据是从公开知识库中抽取的 1000 万条文本块,embedding 维度 1536。生成 embedding 的过程没什么特别,关键点是向量一定要做归一化,很多相似度实现默认用余弦距离,如果输入向量不是单位长度,距离计算和 PQ 聚类都会受影响。
环境安装很常规:
git clone <你的开源项目地址> cd vectiny pip install -r requirements.txt这里提个醒:不要直接把所有 embedding 一次性灌进内存。我一开始生成了 1000 万条向量存成一个大 npz 文件,加载时 16GB 内存直接爆掉。后来改成按分片读入,每片 50 万条,边读边写入索引构建接口,才算稳下来。建议自备一个数据读取脚本,按 50~100 万条一批,批量喂给构建器。
3.2 索引构建与压缩配置
VecTiny 的命令行参数很直白,我用的是这样一组:
vectiny index \ --input ./data/vectors_*.parquet \ --output ./index/ \ --index-type hnsw \ --quantizer pq \ --pq-m 32 \ --pq-kbits 8 \ --opq true \ --num-threads 8 \ --memory-map true各参数含义如下:
| 参数 | 取值 | 作用 |
|---|---|---|
--index-type | hnsw | 使用图索引,查询性能比暴力扫描好很多 |
--quantizer | pq | 使用乘积量化器,而不是标量量化 |
--pq-m | 32 | 把 1536 维切分成 32 个子向量,每个向量最终 32 字节 |
--pq-kbits | 8 | 每个子空间聚成 256 个中心,用 8 bit 表示 |
--opq | true | 开启正交旋转优化,能提升精度 |
--num-threads | 8 | 并行构建子空间聚类和 HNSW 图 |
--memory-map | true | 索引文件按需映射进内存,避免一次性载入过量数据 |
--pq-m 32是整个参数里最关键的一项。它直接决定压缩率和召回精度的平衡点。我试过--pq-m 16,压缩后约 2.5GB,召回召回率比 32 版本高了差不多 0.8 个百分点;试过--pq-m 64,索引文件降到 1.6GB,但召回率又掉了 2 个百分点左右。综合来看,32 这个档位最适合“压缩 97% 还要保住可用精度”的目标。
构建过程耗时也供参考:单机 8 线程,对 1000 万向量做 OPQ+PQ 训练和 HNSW 建图,总共用了大约 2 小时。其中聚类训练主要消费前 20 分钟,后面大部分时间在写 HNSW 图。如果你不想等那么久,可以先用 100 万子集预训练码本,再拿码本给全量数据编码,构建速度会快很多。
3.3 检索验证与参数微调
索引构建完成后,我启动一个简单的检索脚本验证效果:
from vectiny import Index index = Index.load("./index/") results = index.search(query_embedding, top_k=10, rerank=True) print([r.metadata["chunk_id"] for r in results])为了让验证结果有说服力,我并没有只看几个手写 query 的体感。更靠谱的做法是构造一批“以查代测”的正例:每一条 chunk,用它的标题或第一句话作为 query,到全量索引里找它自己,统计召回到 Top 10 的比例。这种方式没有外部人工标注,适合快速摸清压缩后索引的“底线”。在我这轮测试里,1000 条抽样 query,召回到 Top 10 的比例大约 87.5%,比未压缩 float32 索引低了约 2.2 个百分点。这个损耗对很多 RAG 场景是能接受的,尤其是后续还有 LLM 生成的第二道过滤。
如果发现召回率离预期差得多,优先调这几个点:
- 确认 embedding 已经归一化,否则余弦距离会失真;
- 把
--pos-m调低一档再试,比如 32 改成 16; - 把查询时的候选集调大,
ef_search从默认 64 提到 128; - 重排开关一定保持打开,不要用压缩后的距离直接做最终排序;
- 如果数据分布有明显领域特点(比如全是医疗文本),建议用自己的领域子集重新训练 PQ 码本,而不是直接沿用通用演示模型。
4. 常见问题与排查技巧实录
4.1 压缩后召回率下降明显,怎么办
如果召回率掉超过 5 个百分点,通常不是“压缩本来就有损”这么简单,而是某个环节做错了。我在排查时有一个固定顺序:
先检查向量归一化。很多 embedding 模型输出的向量并不是单位向量,但距离计算默认用余弦,如果不归一化,PQ 聚类和查询阶段的“距离表”都会产生系统性偏差。归一化后再测试,召回率往往能回来一个多点。
接着检查码本训练量。PQ 聚类中心是根据样本学出来的,训练集太小(比如只有几万条)会导致中心点不稳定。我在测试时试过只用 5 万条训练码本,召回率比用 100 万条训练时低了近 4 个百分点。建议至少取 10 万条代表性样本做训练,最好是从完整数据里随机抽 10%。
最后检查 OPQ 是否开启。如果项目支持但没有打开,相当于丢失了一个免费的精度提升手段。开启 OPQ 后,子空间割裂造成的“方向性误差”会被旋转抵消一部分,效果普遍好于普通 PQ。
4.2 索引构建太慢,怎么缩短时间
如果构建 1000 万条向量需要好几个小时,先分清慢在哪个阶段。常见瓶颈有三种:embedding 读取太慢、PQ 聚类过慢、HNSW 图构建过慢。
从元凶排查:先看 CPU 占用率,如果一直只有单核在跑,大概率是 embedding 读取或预处理脚本没有并行;如果聚类阶段 CPU 打满但耗时很长,可以把--num-threads调大,或者减少参与训练的样本量;如果已经进入 HNSW 建图阶段,那么可以考虑调低图参数,比如M=16或ef_construction=64,建图时间会明显下降,但查询精度也会轻微下滑。
还有一个非常实用的技巧:先用 100 万条数据跑通整条链路,把码本和图参数定下来,再对剩余的数据做增量写入。这样就不用每次调参都全量重建。首次全量构建时,我还建议把索引输出目录放在 SSD 上,HNSW 的写放大很严重,机械硬盘会慢得非常明显。
4.3 压完之后文件还是超过 2GB,是什么原因
嫌疑最大的原因是目录里保留了未压缩的原始向量副本。有些框架为了支持重排,会在--output目录旁边额外存一份 float32 向量文件,这种事最容易在刚上手时踩到。我第一轮构建完,看目录大小 17GB,差点以为压缩失效,最后发现是把所有原始向量也写进了同一个文件。
第二个原因是元数据。一千万条 chunk,如果每条 chunk 存储大量自定义字段(如 2KB 的审计日志),动辄就是 20GB。这类内容和向量压缩无关,但也应该单独处理:只保留检索必须的字段,原文文本可以单独压缩存储,等需要展示时再解压。
第三个原因是内存映射缓存。--memory-map true会让操作系统在访问索引文件时产生页缓存,这部分缓存会算作内存占用,但在磁盘上并不真实存在。如果你用 du 看目录大小,可能比实际索引文件大。关闭其他程序后再观察,通常能确认状况。
下表是我实测几种组合的表现:
| 配置 | 索引文件体积 | 召回率(Top10) | 单次查询时延 |
|---|---|---|---|
| float32 原始索引 | 68GB | 89.7% | 8ms |
| 仅 int8 标量量化 | 16.8GB | 87.1% | 6ms |
| PQ m=32 + OPQ | 1.9GB | 87.5% | 5ms |
| PQ m=64 + OPQ | 1.6GB | 85.2% | 5.2ms |
可以看到,PQ 方案在时延上并没有因压缩而变慢,反而因为数据量小、cache 命中率高,整体响应更稳定。这个结果和我预期的基本一致。
4.4 查询时偶发长延迟
如果你发现大多数请求在 5ms 左右,但偶尔会跳到几百毫秒,很大概率是“懒加载”导致的缺页中断。使用 mmap 启动时不会加载全部数据,但当第一次访问某些索引块时,操作系统要从磁盘拉数据,于是出现尖刺。解决办法很简单:在服务启动后,先做一次全索引预热查询,比如随机拿几条 query 连续跑几十遍,把索引涉及的主要区域都推进 OS 缓存。这样后续查询基本不会再碰到冷缺页。
如果你是在非常低配的机器上跑,也可以在项目配置里启用“预加载到内存”模式。代价是启动时内存占用上升,但查询时延会更平滑。我这里测下来,2GB 索引预加载到内存后,启动时间多了十几秒,但长尾时延几乎消失。
5. 这套方案适合谁,不适合谁
5.1 哪些场景建议直接上
我在做完这轮验证后,最大的体会是“压缩 97%”并不是一个为了炫技的数字,它对两类场景特别有价值。一类是本地的、私有化部署的知识库:机器配置有限,又要求数据不出内网,能在一个 16GB 笔记本上跑通千万级 RAG,这给很多团队省了高配 GPU 服务器成本。另一类是检索并发不高的工具类应用:比如个人文档问答、内部员工搜索,平均 QPS 在个位数到两位数,PQ 压缩带来的精度损失可以被后续 LLM 总结环节吸收。
5.2 哪些场景需要谨慎
如果你的应用非常依赖“精确语义排序”,比如风控、法律条文检索,那 97% 压缩这种方案就有些冒险。PQ 的本质是用近似距离换空间,即使有重排,最终精度也大概率不如原版 float32 索引。再有就是向量维度比较低的情况,比如用 128 维的轻量 embedding 模型,原始向量每条只有 512 字节,压缩到 32 字节确实也可以,但可压水空间不如 1536 维那么大,召回损失会被放大,未必划算。
还有一种情况不要勉强:索引更新非常频繁,比如每分钟都会有大量新增数据。PQ 码本一旦确定,后续新向量都要基于旧码本编码;如果数据分布随时间漂移,旧码本会越来越不匹配。你需要定时重训码本并重建索引,这会带来额外的维护成本。对这种场景,优先考虑支持在线更新且码本更新成本较低的中等压缩方案,而不是一刀切压到 97%。
5.3 最后一个我很想提醒的细节
别把压缩率当成唯一指标。我在项目里设计了一套“三位一体”评估表:索引体积、召回率、查询时延三个数永远放在一起看。只盯体积会让精度悄悄流失;只盯召回率又会回到原始向量那条老路。压缩 97% 只是一个引子,真正要解决的是在可控精度下把数据放回本地。如果数据规模还不到千万级,其实没必要上 PQ;但如果已经踩着内存边界,我推荐先用 10 万级子集做一轮快速测试,把上表那三个数字量出来,再决定要不要全面压扁。