
1. 为什么本地跑千万级 RAG 这么难1.1 向量库的“大”到底大在哪做 RAG 的应用基本都绕不开一个问题embedding 一跑向量库就越来越肥。很多人第一次算账时会愣住一千万条常见规格的向量1536 维float32光原始向量就要 1536 × 4 字节 ≈ 6.14KB一千万条差不多 61.4GB。这个数量级有多夸张一篇文章按 500 字切块通常能产出几十到几百个 chunk一个几十万篇文档的知识库切出千万级 chunk 并不稀奇。于是文档还没索引完磁盘先报警了。但向量库不只有“原始向量”这一层大头。为了做相似度检索你大概率会用 HNSW 之类的图索引。HNSW 会在每个向量周围维护若干条“邻居边”常见配置 M32 时每条边至少要存目标向量 id4字节和一些状态位双向图再加层级一千万点大概会产生 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~4GBembedding 模型 1~2GBLLM 推理留 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 压进 2GB3.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-typehnsw使用图索引查询性能比暴力扫描好很多--quantizerpq使用乘积量化器而不是标量量化--pq-m32把 1536 维切分成 32 个子向量每个向量最终 32 字节--pq-kbits8每个子空间聚成 256 个中心用 8 bit 表示--opqtrue开启正交旋转优化能提升精度--num-threads8并行构建子空间聚类和 HNSW 图--memory-maptrue索引文件按需映射进内存避免一次性载入过量数据--pq-m 32是整个参数里最关键的一项。它直接决定压缩率和召回精度的平衡点。我试过--pq-m 16压缩后约 2.5GB召回召回率比 32 版本高了差不多 0.8 个百分点试过--pq-m 64索引文件降到 1.6GB但召回率又掉了 2 个百分点左右。综合来看32 这个档位最适合“压缩 97% 还要保住可用精度”的目标。构建过程耗时也供参考单机 8 线程对 1000 万向量做 OPQPQ 训练和 HNSW 建图总共用了大约 2 小时。其中聚类训练主要消费前 20 分钟后面大部分时间在写 HNSW 图。如果你不想等那么久可以先用 100 万子集预训练码本再拿码本给全量数据编码构建速度会快很多。3.3 检索验证与参数微调索引构建完成后我启动一个简单的检索脚本验证效果from vectiny import Index index Index.load(./index/) results index.search(query_embedding, top_k10, rerankTrue) 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 建图阶段那么可以考虑调低图参数比如M16或ef_construction64建图时间会明显下降但查询精度也会轻微下滑。还有一个非常实用的技巧先用 100 万条数据跑通整条链路把码本和图参数定下来再对剩余的数据做增量写入。这样就不用每次调参都全量重建。首次全量构建时我还建议把索引输出目录放在 SSD 上HNSW 的写放大很严重机械硬盘会慢得非常明显。4.3 压完之后文件还是超过 2GB是什么原因嫌疑最大的原因是目录里保留了未压缩的原始向量副本。有些框架为了支持重排会在--output目录旁边额外存一份 float32 向量文件这种事最容易在刚上手时踩到。我第一轮构建完看目录大小 17GB差点以为压缩失效最后发现是把所有原始向量也写进了同一个文件。第二个原因是元数据。一千万条 chunk如果每条 chunk 存储大量自定义字段如 2KB 的审计日志动辄就是 20GB。这类内容和向量压缩无关但也应该单独处理只保留检索必须的字段原文文本可以单独压缩存储等需要展示时再解压。第三个原因是内存映射缓存。--memory-map true会让操作系统在访问索引文件时产生页缓存这部分缓存会算作内存占用但在磁盘上并不真实存在。如果你用 du 看目录大小可能比实际索引文件大。关闭其他程序后再观察通常能确认状况。下表是我实测几种组合的表现配置索引文件体积召回率Top10单次查询时延float32 原始索引68GB89.7%8ms仅 int8 标量量化16.8GB87.1%6msPQ m32 OPQ1.9GB87.5%5msPQ m64 OPQ1.6GB85.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 万级子集做一轮快速测试把上表那三个数字量出来再决定要不要全面压扁。