基于嵌入量化的大规模文档搜索
4000 万份文档。单 CPU。无需 GPU。
二元搜索加上 int8 重评分如何悄然改写大规模语义搜索的规则。
当你长时间使用嵌入技术时,你会遇到一种非常特殊的挫败感。
你终于实现了检索。它很准确。感觉很智能。你为此感到自豪。然后你看了看账单。
或者更糟,看看内存使用情况。
或者更糟——当你意识到你“简单”的语义搜索仅仅为了运行就需要 180GB 的内存时。
这时,一个悄悄的假设就会悄然出现:“好吧……我想这就是正确进行向量搜索的代价吧。”
但也许并非如此。
因为接下来这件事让我停下了滚动页面:
你只需 8GB 内存和 45GB 硬盘,就能在 CPU 上用大约 200 毫秒的时间搜索 4000 万条文本。
无需 GPU。无需特殊硬件。无需任何魔法。
只需一种精心设计的推理策略。
一旦你看到了这一点,就无法再视而不见。
1、关于 fp32 嵌入的令人不安的真相
让我们大声说出来。
我们大多数人默认使用 float32 嵌入,因为模型就是这么给出的。我们视它们为神圣不可侵犯。要么高精度,要么什么都不要。
但想想我们在检索过程中究竟在做什么。
我们不是在解物理方程,而是在对文档进行排序。
而且,排序,尤其是在漏斗顶端,容错率出奇地高。
这就是整个方法的漏洞所在。
2、诀窍
一旦揭开神秘面纱,你会发现这套设置几乎乏味:
二分查找用于召回率。Int8 重评分用于精确率。
就是这样。
你不会丢弃质量,而是对其进行优化。
你先让低成本的表示方法完成繁重的工作,然后在真正重要的地方再提升精确率。
以下是完整流程的工作原理。
3、推理策略,逐步详解
让我们慢慢来,就像一起调试一样。
步骤 1:嵌入查询
我们从你预期的地方开始。
一个密集嵌入模型。一个标准的 fp32 向量。
#1. Embed the query as float32
start_time = time.time
query_embedding = model.encode_query (query)
embed_time = time.time() start_time目前还没有什么特别之处。这是你的参考信号,也是你之后会信赖的东西。
步骤 2:将查询量化为二进制
现在,思维方式发生了第一次转变。
我们将 float32 查询嵌入量化为二进制。语义方向相同,但表示形式缩小了 32 倍。
#2. Quantize the query to ubinary
start_time = time.time
query_embedding_ubinary = quantize_embeddings(
query_embedding.reshape(1, 1), "ubinary"
)
quantize_time = time.time() start_time这一步非常快,快得几乎有点可疑。
而且它为后续所有步骤奠定了基础。
步骤 3:搜索二元索引
在这里,规模不再令人担忧。
二元索引体积小巧,速度快,而且内存占用低。
您可以选择精确搜索或近似搜索。
#3. Search the binary index (either exact or approximate)
index = binary_ivf_index if use_approx else binary_index
start_time = time.time()
_scores, binary_ids = index.search
query_embedding_ubinary, top_k rescore_multiplier
)
binary_ids = binary_ids[0]
search_time = time.time() start_time与其问“4000 万个文档中哪个最好?”
不如问“哪 40 个文档看起来有希望?”
这种重新定义问题至关重要。
步骤 4:加载最佳候选结果的 int8 词嵌入
现在我们已经缩小了范围,接下来需要引入更多信号。
我们仅加载排名靠前的二元匹配结果的 int8 词嵌入。
#4. Load the corresponding int8 embeddings
start_time = time.time()
int8_embeddings = np.array(
title_text_int8_dataset [binary_ids]["embedding"], dtype=np.int8
)
load_int8_time = time.time() start_time数据量仍然很小,成本仍然很低,但比二元匹配更具表达力。
步骤 5:使用原始 fp32 查询重新评分
此时精度得以恢复。
我们使用原始 fp32 查询词嵌入,并将其与 int8 文档词嵌入进行比较评分。
#5. Rescore the top_k rescore_multiplier using the float32 query embedding and the int8 document embeddings
start_time = time.time()
scores = query_embedding @ int8_embeddings.T
rescore_time = time.time() start time这一步几乎不耗费任何资源。而且它能帮你找回之前“丢失”的大部分质量。
步骤 6:排序并选择最佳结果
#6. Sort the scores and return the top k
start_time = time.time()
indices = scores.argsort() [::-1][:top_k]
top_k_indices = binary_ids[indices]
top_k_scores = scores [indices]
sort_time = time.time() start_time至此,排名基本完成。
步骤 7:加载实际文本
现在才需要处理标题、URL 和内容。
#7. Load titles and texts for the top k results
start_time = time.time()
top_k_titles = title_text_int8_dataset [top_k_indices] ["title"]
top_k_urls = title_text_int8_dataset [top_k_indices] ["url"]
top_k_texts = title_text_int8_dataset [top_k_indices] ["text"]
top_k_titles = [f" [{title}] ({url})" for title, url in zip(top_k_titles, top_k_urls)]
load_text_time = time.time() start_time这是加载文本的准确时间。
不能提前加载。
4、真正重要的数字
以下是对超过 4000 万条维基百科文本的实际运行结果:
Embed Time: 0.0833 s
Quantize Time: 0.0000 s
Search Time: 0.0464 s
Load int8 Time: 0.0263 s
Rescore Time: 0.0006 s
Sort Time: 0.0000 s
Load Text Time: 0.0192 s
Total Retrieval Time: 0.0925 s再读一遍。
全程耗时不到 100 毫秒。基于 CPU。
你可以在这里立即亲自尝试,无需登录。
5、为什么这种方法有效
感觉不对劲是因为我们被教导要不惜一切代价维护精确性。
但检索并非分类,也非回归,甚至不是生成。
它是筛选。
二进制嵌入在排除某些内容方面表现出色。Int8 嵌入擅长对候选对象进行排序。FP32 最适合真正需要细微差别的情况——但当文档数量减少到 40 个时,这种情况就很少见了。
因此,与其处处消耗 FP32 的成本,不如像聚光灯一样,逐步提升精度。
先用宽光束,后用窄光束。
6、存储和内存
让我们将其与标准的 FP32 设置进行比较。
Float32 数据检索(基准)
- 内存:约 180GB
- 磁盘:约 180GB
- 速度:慢
- 成本:高
二进制 + int8 数据检索
- 二进制索引:缩小 32 倍
- int8 嵌入:缩小 4 倍
- 内存使用量:约 6GB
- 磁盘使用量:约 45GB
关键在于:您只需将二进制索引保存在内存中。
这才是真正节省成本的地方。
7、性能权衡
我们不能假装没有损失。
以下是实验结果:
| 表示 | 索引大小 | 速度 | 性能 |
|---|---|---|---|
| float32 | 1 倍 | 1 倍 | 100% |
| int8 / uint8 | 缩小 4 倍 | 速度提升高达 4 倍 | 约 99.3% |
| 二分查找 / 超二分查找 | 体积缩小 32 倍 | 速度提升高达 45 倍 | 约 96% |
现在来说说重点。
通过二分查找检索更多候选结果,并使用 int8 重新评分,您可以恢复约 99% 的 fp32 质量。
这最后的 1% 几乎不需要任何成本。
8、这在实际应用中会带来哪些改变?
说实话?改变很大。
这意味着:
- 您不再需要 GPU 来进行大规模语义搜索
- 您可以在普通硬件上实现强大的检索功能
- 您无需再为 fp32 支付“奢侈税”
- 您可以扩展数据集而无需重写所有代码
也许更大的转变在于思维方式。
我们一直把嵌入向量当作易碎的玻璃。
它们不是。
它们是工具。而工具只有在您不再用同一把锤子敲所有钉子时才能发挥最佳作用。
如果您只能记住一点:
分阶段思考。
不要问“最精确的表示是什么?”,而要问“在流程的这个阶段,什么精度才足够?”
二进制优先,Int8其次,精度最后。
一旦你理解了这种检索方式,再去到处用暴力破解的FP32格式就显得……有点不负责任了。
也许这才是真正值得我们深思的教训。
并非说量化有多么高明。
但即便在巨型模型时代,这种工程判断仍然胜过暴力破解。
原文链接:Search 40M documents in under 200ms on a CPU using binary embeddings and int8 rescoring.
汇智网翻译整理,转载请标明出处