跳转到主要内容

这是本节的多页打印视图。 .

返回本页常规视图.

工程实践与深度解析

关于 RaBitQ 量化、DiskANN 与 HNSW 对比、BM25 混合搜索、ColBERT 重排序以及大规模 PostgreSQL 向量基准测试的技术文章。

1 - 在 PostgreSQL 上将向量搜索扩展至 10 亿规模

单机 2 小时内完成极速索引构建

对于希望自行部署十亿级规模向量搜索的团队而言,真正的挑战往往不在于原始性能,而在于实用性。许多专为十亿级规模、低延迟向量搜索而设计的解决方案都伴随着诸多现实约束,需要在各种权衡中做出妥协,从而影响了落地的难易程度。

现有的大多数方案通常属于以下几类之一:

  • 运维复杂:依赖多节点分布式系统,长期管理、运维和维护的难度较高。

  • 构建耗时过长:需要漫长的索引构建时间,导致重建索引成本高昂,且极易影响生产环境负载。

  • 内存开销巨大:单机往往需要高达 1 TB 的内存,高昂的硬件成本让大多数团队望而却步。

这些权衡在现有的公开基准测试中清晰可见。例如,ScyllaDB 报告在 DEEP-1B 数据集上实现了高达 98% 的召回率和 12.3 ms 的 P99 延迟,但这一结果依赖于多台大型实例。而在相同数据集和规模下,YugabyteDB 报告的长尾延迟明显更高,P99 达到了 0.319 秒。

延迟 / 召回率 硬件配置 构建时间
ScyllaDB 13ms / 98% 3×AWS r7i.48xlarge + 3×AWS i4i.16xlarge 24.4 小时
YugabyteDB 319 ms / 96% 未报告 未报告
VectorChord 40 ms / 95% AWS i7ie.6xlarge 1.8 小时

在单节点部署方面,Scalable Vector Search (SVS) 此前的研究表明,使用 HNSWlib 为 DEEP-1B 构建索引通常需要 800 GiB 内存;尽管 SVS 或 FAISS-IVFPQs 可以将其降低至约 300 GiB,但这依然是一笔可观的硬件开销。

这使得希望坚持自托管的团队很难实现平滑扩展。从 100 万向量扩展到 10 亿向量,往往需要重新思考架构:要么改变硬件假设,要么重构业务工作流。

借助 VectorChord 1.0.0,规模扩展变得前所未有地简单。得益于全新的层次化 K-means(Hierarchical K-means)及其他优化,构建 10 亿向量索引与构建 100 万向量索引的流程完全一致。这也是我们实际使用它的方式:只需迁移到一台配置稍高一点的机器,例如从 AWS i7i.xlarge 升级到 i7i.4xlarge。

DEEP-1B 基准测试

为了验证 VectorChord 在十亿级向量规模下的能力,我们采用了来自 BIGANN 的 Yandex DEEP-1B 数据集。DEEP-1B 是大规模向量搜索领域广泛采用的基准数据集,包含由自然图像训练的深度学习模型生成的 10 亿条 96 维向量嵌入(embeddings)。

由于其庞大的规模,DEEP-1B 被广泛用作评估大规模向量搜索系统的基准数据集。其广泛的普及度使得测试结果易于复现和对比,尤其是在评估十亿级向量规模下的索引构建性能、查询延迟和资源效率时。

面对如此庞大的数据集,构建 VectorChord 索引需要:

  • 存储:约 900 GB 的高性能 SSD

  • 内存(shared_buffers):不低于 60 GB,由 PostgreSQL 管理

  • 内存(额外开销):索引构建期间至少需要 60 GB 额外内存

基于上述要求,AWS i7i.4xlarge 是该规模下构建索引的最低配置。我们的测试在一台 AWS i7ie.6xlarge 上运行,这反映了实际生产部署中的常见做法——通过配置更多内存来减少磁盘访问并保持稳定的查询延迟。

实例类型 AWS i7i.4xlarge AWS i7ie.6xlarge
物理处理器 Intel Xeon Scalable (Emerald Rapids) Intel Xeon Scalable (Emerald Rapids)
vCPU 数量 16 24
内存 (GiB) 128 192
磁盘空间 (GiB) 3750 GB NVMe SSD 2×7500 GB NVMe SSD
价格 $1088 / 月 $2246 / 月

所有实验均在 PostgreSQL 17 上使用 VectorChord 1.0.0 运行。下方 SQL 是我们用于构建索引的完整命令:

CREATE INDEX ON deep USING vchordrq (embedding vector_l2_ops) WITH (options = $$
build.pin = 2
residual_quantization = true
[build.internal]
build_threads = 24
lists = [800, 640000]
kmeans_algorithm.hierarchical = {}
$$);

构建配置中各个选项的作用如下:

  • build.pin:启用构建期内存固定(pinning),将索引的热点部分缓存在共享内存中,从而加速大规模数据集上的索引构建。

  • residual_quantization:在 DEEP-1B 上,我们发现启用**残差量化(residual quantization)**能够提升查询性能,因此在本基准测试中保持开启。

  • build.internal.build_threads:在 K-means 构建阶段使用 24 个线程,充分跑满实例上的可用 CPU 资源。

  • build.internal.lists:根据我们的经验,应根据数据行数选择合适的聚类列表大小。在大规模场景下,使用**双层列表(two-level list)**效果显著,能同时提升索引构建效率与查询性能。

  • build.internal.kmeans_algorithm.hierarchical:启用 VectorChord 1.0 引入的**层次化 K-means(Hierarchical K-means)**路径,大幅加快大规模索引的构建速度。

测试结果

在单台 AWS i7ie.6xlarge 机器上使用 24 个 CPU 核心时,索引构建仅耗时 **6,408 秒(约 1.8 小时)**即告完成,证明十亿级规模的索引构建完全可以在切实可行的维护窗口内完成。

下图展示了在缓存预热(warm cache)状态下,使用单搜索线程在 Deep1B 数据集上评估 Top-10 和 Top-100 查询时的查询吞吐量(QPS)与召回率(Recall)关系。

对于 Top-10 查询,吞吐量从约 0.91 召回率下的超过 117 QPS,变化至约 0.95 召回率下的约 69 QPS。Top-100 查询也遵循相同的规律,随着召回率的提高,吞吐量相应下降。

下表列出了每个数据点对应的具体搜索参数(固定 epsilon = 1.9,改变探测聚类数 probes)。综合图表数据可以看出,即使在 10 亿向量规模下,VectorChord 依然能在单机上提供稳定且可调优的查询性能。

probes / epsilon Recall@Top 10 QPS P99 延迟 / ms
40,120 / 1.9 0.9132 117.45 12.33
40,160 / 1.9 0.9305 97.32 14.61
40,250 / 1.9 0.9511 68.87 20.53
probes / epsilon Recall@Top 100 QPS P99 延迟 / ms
40,180 / 1.9 0.9051 66.12 22.60
40,270 / 1.9 0.9318 49.55 30.40
40,390 / 1.9 0.9509 37.53 39.70

这些结果表明,即便在十亿级规模下,单机上的向量查询性能依然可以做到实用且可预测。

总结

VectorChord 1.0.0 证明了实时向量搜索可以从 100 万向量平滑扩展至 10 亿向量,而无需强迫用户改变架构、工作流或使用方式。无论您是在构建以图搜图、AI 驱动的应用,还是 RAG 流水线,VectorChord 都旨在成为运行在您自己机器上的可靠向量引擎,随数据增长而自然扩展。

准备好扩展规模了吗?您可以立即开始使用,也可以通过 GitHub 或 Discord 联系我们,了解更多信息并获取社区支持。

2 - 我们如何在 PostgreSQL 上实现 20 分钟构建 1 亿向量索引

在过去几个月里,我们收到了来自用户和合作伙伴的一致反馈:尽管我们致力于提供可扩展、高性能的 pgvector 替代方案,但在十亿级规模下,索引构建时间和内存占用仍然是核心痛点……

1. 引言

在过去几个月里,我们收到了来自用户和合作伙伴的一致反馈:尽管我们提供可扩展、高性能 pgvector 替代方案的目标广受好评,但在十亿级规模下,索引构建时间和内存占用仍然是大家关注的主要痛点。

现在,VectorChord 可以在一台仅配备 12 GB 内存的 16 vCPU 机器上,在 20 分钟内为 1 亿个 768 维向量构建索引。相比之下,使用 pgvector 为相同规模的数据构建索引,在 16 核实例上需要大约 200 GB 内存和约 40 小时。而且当内存不足时,pgvector 往往会频繁触发页面交换(page swapping),导致构建速度进一步恶化。

简而言之,内存占用和构建时间已成为大规模部署向量检索的关键障碍。通过一系列有针对性的优化,我们将构建时间缩短至 20 分钟,内存占用降低了 7 倍,而精度损失微乎其微。

借助这些改进,我们现在可以使用内存小得多、价格低廉得多的机器(且无需 GPU),依然能够承载 1 亿个 768 维向量:

实例规格 价格 已用内存 / 总内存
优化前最低配置 Amazon i7i.8xlarge 🟨 $2174 / 月 135 GB / 256 GB
推荐快速建索引配置 Amazon i7i.4xlarge ✅ $1087 / 月 12 GB / 128 GB
最低配置 Amazon i7i.xlarge + 建索引时使用 GPU ✅ $272 / 月 + GPU 成本 6 GB / 32 GB

在接下来的章节中,我们将介绍如何优化各个阶段,使索引构建更快、更省内存。各项优化按阶段划分如下,每项优化分别针对一个阶段:

优化手段 目标阶段 优化效果
层次化 K-means ➕ 降维 1️⃣ 初始化(Initialization) 🚀 30 分钟 (GPU) → 8 分钟 (CPU) 🧾 135 → 23 GB
减少锁竞争 2️⃣ 插入(Insertion) 🚀 420 分钟 → 9 分钟
并行化压缩 3️⃣ 压缩(Compaction) 🚀 8 分钟 → 1 分钟

2. 背景

VectorChord 中使用的索引类型 vchordrq 在逻辑上是一棵高度为 \(n+1\) 的树。树的前 \(n\) 层是不可变的,纯粹作为检索时的路由结构;第 \((n+1)\) 层则存储所有实际数据。

如果 \(n=1\),索引是一个扁平的、无分区的结构;如果 \(n=2\),它就是一个倒排文件(IVF)索引;如果 \(n=3\),则会多出一层路由结构。

索引构建过程可以分为 3 个阶段:初始化(Initialization)、插入(Insertion) 和 压缩(Compaction)。

  • 初始化阶段(Initialization Phase)
    在此阶段,树的前 \(n\) 层被写入索引。首先,索引对表中的向量进行采样;然后,通过对样本进行聚类、对聚类中心再聚类,以此类推构建 \(n\) 层树结构;最后将这棵树写入索引。

  • 插入阶段(Insertion Phase)
    索引将表中的向量插入到树的最底层。

  • 压缩阶段(Compaction Phase)

    索引将所有已插入的向量从非紧凑布局(non-compact layout)转换为紧凑布局(compact layout)。

3. 让聚类更快、更省内存

过去,虽然我们可以在小规格实例上为 1 亿向量构建索引,但通常需要借助 GPU 来加速聚类计算。

初始化阶段的主要瓶颈在于聚类,它既耗时又消耗大量内存。事实上,正是聚类决定了构建索引的最低内存需求。如果我们能在 CPU 上以既快速又省内存的方式实现聚类,那么在没有大内存和 GPU 的小规格实例上构建索引就变得切实可行了。

设 \(n\) 为向量数量,\(c\) 为聚类中心(质心)数量,\(d\) 为向量维度,\(l\) 为迭代次数。K-means 的时间复杂度为 \(O(ncdl)\),空间复杂度为 \(O(nd + cd)\)。设 \(f\) 为采样系数,即 \(n=fc\)。此时 K-means 的时间复杂度为 \(O(fc^2dl)\),空间复杂度为 \(O(fcd)\)。

在接下来的小节中,我们将解释如何降低算法复杂度,并减小 \(d\) 和 \(f\) 以获得更好的性能。

层次化 K-means

受限于时间复杂度,无论应用何种工程优化,标准 K-means 都无法突破线性加速的上限。即使在 GPU 上,这一过程也需要 30 分钟。因此我们必须从算法层面降低时间复杂度。

一个直观的思路是将样本划分为多个互不相交的子集,在每个子集上分别运行 K-means,然后再合并各个子集生成的聚类中心。为了平衡子集的大小与子集的数量,我们选择 \(\sqrt{c}\) 作为子集数量。为了生成这 \(\sqrt{c}\) 个子集,我们先执行一次小规模的 K-means,然后利用得到的 \(\sqrt{c}\) 个质心将 \(n\) 个向量分配到 \(\sqrt{c}\) 个互不相交的子集中。

假设各子集大小均匀,这一步的时间复杂度为 \(O(f\sqrt{c}\sqrt{c}dl) \times \sqrt{c} = O(fc^{1.5}dl)\)。如果 f = 64 且 c = 160,000,该算法的理论速度将提升约 400 倍。

这里还有一个小问题:每个子集应该分配多少个质心?如果忽略质心数量必须为整数的约束,答案是 \(\frac{n}{|s|}c\)。考虑到整数约束后,这个问题就类似于比例代表制中的席位分配问题,其中圣拉古法(Sainte-Laguë method)是一种能够最小化平均席位与选票比例偏差的算法。其工作原理如下:

在统计完所有选票后,依次为每个政党计算商数。商数的计算公式为 \(\frac{V}{s_i+0.5}\),其中 \(V\) 是该政党获得的总票数,\(s_i\) 是目前为止已分配给该政党的席位数(所有政党初始值均为 \(0\))。得数最高的政党获得下一个席位,随后重新计算其商数,重复此过程直至所有席位分配完毕。

现在,在 CPU 上进行聚类已经切实可行了。然而,这一算法本身并没有降低内存占用。

降维

现在让我们回过头来看 K-means 样本所占用的 140 GB 内存。在一台 128 GB 内存的机器上,这必然会导致内存溢出(OOM)。考察空间复杂度 \(O(fcd)\),我们有两种降低内存占用的途径:减小 \(f\),以及减小 \(d\)。

我们先来减小 \(d\)。虽然听起来有些不可思议,但我们确实可以先对向量进行降维,再执行聚类,而且不会损害最终的精度。Christos 的研究成果表明,在低维投影上运行 K-means 依然能够保持良好的聚类精度。

Johnson–Lindenstrauss 引理指出,高维空间中的一组点可以嵌入到维度低得多的空间中,且点与点之间的距离基本保持不变。在该引理的经典证明中,这种嵌入是通过随机正交投影实现的。

根据该定理,\(n\) 个向量可以降维到 \(O(\lg n)\) 维。具体而言,我们只需要构造一个随机高斯矩阵,通过矩阵乘法将高维向量降维为低维向量,然后在低维空间上执行 K-means。

由于我们的目标是降低内存占用,因此在采样阶段我们就直接应用 Johnson-Lindenstrauss 变换。最终我们会得到低维空间下的质心。我们并不尝试进行逆变换;相反,我们会再次对表进行采样,在经过 Johnson-Lindenstrauss 变换后的低维空间中找到最近的聚类簇,从而恢复出高维空间下的质心。

将维度从 768 降至 100 后,实例的常驻内存集大小(RSS)降至 23 GB,使我们能够在 i4i.xlarge 实例上完成索引构建。此外,理论上这也会让聚类速度提升 7 倍。结合层次化 K-means 与降维技术后,初始化阶段的耗时降至 24 分钟。

减小 \(f\) 则非常简单。它对应配置项 build.internal.sampling_factor,因此我们只需修改配置即可。将 \(f\) 设为 \(64\) 后,实例的常驻内存集大小进一步降至 6 GB,聚类速度也大约提升了 2 倍。

采样

为了执行聚类,我们需要从表中对向量进行采样。我们此前采用的方法是水塘抽样(reservoir sampling),它虽然可靠但速度较慢。之前之所以使用该方法,是因为如果不进行全表扫描,我们无法预知表中的总行数;然而水塘抽样本身仍然需要执行全表扫描。

为了避免全表扫描,我们利用了 PostgreSQL 表访问方法(Table Access Method)的采样接口。该接口接收一个函数,该函数在给定最大块号(block number)时生成一个块号迭代器,随后接口会返回这些数据块中元组(tuple)的迭代器。为了生成这样一个随机块号迭代器,我们可以先生成一个有序序列,再对其执行 Fisher–Yates 洗牌算法,但这会消耗可观的内存。事实上,我们有一种更巧妙的方法:在密码学中,伪随机置换(pseudorandom permutation)是一种在统计上无法与真正随机置换区分开来的函数。

Feistel 网络(Feistel network)可以用作伪随机置换。其定义为 \(L_{i + 1} = R_{i}, R_{i + 1} = L_{i} \oplus F(R_i, K_i)\),其中 \(F\) 是哈希函数,\(K_i\) 是随机种子。该函数的输入为 \((L_0, R_0)\),输出为 \((L_n, R_n)\)。因此它是一个从 \([0, 2^n) \times [0, 2^n)\) 到 \([0, 2^n) \times [0, 2^n)\) 的映射。巧妙之处在于,由于 \(R_{i} = L_{i + 1}, L_{i} = R_{i + 1} \oplus F(L_{i + 1}, K_i)\),该函数是可逆的。可逆函数必然是双射(bijective)的,而 \([0, 2^n) \times [0, 2^n)\) 等价于 \([0, 4^n)\),因此它构成了 \([0, 4^n)\) 上的一个置换。现在,只需过滤掉该置换中所有大于最大块号的元素,我们就得到了所需的惰性求值随机置换(lazy random permutation)。

基于该接口和这一置换函数,我们实现了块级采样(block sampling),从而只需访问被采样命中的向量数据块。

综合以上所有优化,初始化阶段现在的总耗时仅需 8 分钟。

4. 减少锁竞争

在早期的实验中,如果在 Amazon i7i.16xlarge(64 vCPU)实例上使用 \(n=2\) 为 LAION-100m 数据集构建索引,插入阶段大约需要 420 分钟,且完全受限于计算瓶颈(computation-bound)。

从 0.1 版本开始,VectorChord 允许将 \(n\) 设置为不大于 \(8\) 的正整数。在我们看来,这对于十亿级规模的数据是必不可少的。不过在当时,我们并不清楚它具体能带来多大的加速效果。

在规格更小的 i7i.4xlarge(16 vCPU)实例上尝试 \(n=3\) 后,我们观察到插入阶段仅需 40–60 分钟即可完成。此时 CPU 利用率保持在 40% 左右,IO 吞吐量在 300 MB/s 到 800 MB/s 之间波动,这表明其中存在巨大的优化空间。

减少链表竞争

插入阶段耗时 40–60 分钟。令人惊讶的是,我们的测试显示使用 8 个工作进程(worker)耗时 40 分钟,而使用 16 个工作进程反而耗时 55 分钟。这表明工作进程在插入过程中存在潜在的锁竞争。

在具体实现中,索引除了树结构之外,还维护了一个单链表用于存储全精度向量,而树中仅存储量化后的向量。这使得树节点体积小得多,从而能将整棵树放入内存中。

将 \(n\) 从 \(2\) 改为 \(3\) 后,插入阶段的计算量大幅减少,导致向该链表插入向量的操作变得更加频繁。并行工作进程在向同一个链表插入数据时产生了严重竞争,因此增加工作进程反而拖慢了插入速度,导致性能表现难以预测。

为了解决这个问题,我们将单个链表替换为 \(1+k\) 个链表。第一个链表存储树的前 \(n\) 层所用的全精度向量,其余 \(k\) 个链表存储最底层节点的向量。在构建索引期间,第 \(i\) 个工作进程将向量插入到第 \((i\ \text{mod}\ k)\) 个链表中。我们将默认值设为 \(k=32\),这足以应对大多数场景。

完成这一改动后,CPU 利用率稳定在 54% 左右,插入阶段耗时缩短至约 30 分钟。

减少页面扩展锁竞争

此时的 CPU 利用率仍然暗示还有进一步优化的潜力。但瓶颈究竟在哪里?我们首先使用 htop 检查了 PostgreSQL 的工作进程状态。

许多进程的标题显示为 waiting,表明 PostgreSQL 内部存在严重的锁竞争。通过在源码中搜索,我们追踪到设置 waiting 状态的代码位于 lock.c。为了测量进程脱离 CPU(off-CPU)的时间,我们使用了 BCC 工具集中的 offcputime。随后,借助 Brendan Gregg 的 FlameGraph 中的 stackcollapse.pl 和 flamegraph.pl,我们生成了进程 off-CPU 时间的火焰图。

结果令人意外:罪魁祸首竟然是 LockRelationForExtension,它在扩展索引文件时负责获取索引的扩展锁。

提示

图中显示的 ockRelationForExtension 实际上应为 LockRelationForExtension。这可能是 flamegraph.pl 脚本的某种未知行为导致首字母被截断。

为什么获取这个锁会成为瓶颈?检索 PostgreSQL 邮件列表后,我们找到了这篇讨论。

简而言之,PostgreSQL 会对每个索引加锁,以防止该索引被并发扩展,但这个锁的粒度过于粗放。感谢 Andres Freund 提交的补丁,它缩小了临界区(critical section)并修复了这一问题,但该优化需要使用 PostgreSQL 16 引入的新 API。

由于 VectorChord 支持 PostgreSQL 13 至 18,我们在早期开发中使用了旧版 API,不幸因此错过了这项优化。

切换到新 API 后,插入阶段的耗时缩短到了 22 分钟。

批量页面扩展

然而,新一轮的性能剖析显示,瓶颈依然停留在同一区域。

既然锁的临界区已经缩小,我们就需要加快页面扩展本身的速度来缓解瓶颈。在文件系统中,使用 fallocate 扩展文件非常快。如果使用 fallocate 来扩展索引,扩展单个页面的平均耗时就会大幅缩短。那么问题来了:我们能否使用 fallocate 来扩展索引?

答案是肯定的。如果单次扩展索引的页面数超过 8 页,PostgreSQL 会自动从 pwrite 切换到 fallocate。通过每次一次性申请 16 个页面,我们显著提升了页面扩展的速度。

完成这一改动后,插入阶段耗时骤降至 9 分钟,CPU 利用率稳定在 90%,写入吞吐量保持在 1.8 GB/s 左右。iostat 报告的 IO 利用率达到 0.75–0.85,表明我们终于充分利用了硬件资源。

这里仍然存在进一步调优的空间,但就目前而言,插入阶段已不再有任何明显的瓶颈。

5. 并行化压缩

在树的最底层,量化向量以两种布局形式存在:

  • 非紧凑布局(面向插入):每个量化向量独立存储为一个元组,因此可以直接追加数据,而无需修改现有元组。

  • 紧凑布局(面向检索):每 32 个量化向量打包存储为一个元组。该布局针对 SIMD 指令集进行了优化,能够大幅提升检索速度。

所有向量在最初插入时都采用非紧凑布局,并在最后一个阶段统一转换为紧凑布局。值得注意的是,此前这一阶段是串行执行的,在索引构建过程中大约耗时 8 分钟。

由于前两个阶段的速度已经大幅提升,优化压缩阶段变得愈发重要。因此我们将这一阶段并行化:假设有 \(k\) 个工作进程,树的第 \(n\) 层有 \(m\) 个节点,那么第 \(i\) 个节点的子节点将由第 \((i\ \text{mod}\ k)\) 个工作进程负责压缩。得益于并行化,压缩阶段现在的耗时不到 1 分钟。

你可能会注意到,为了维持检索性能,高效索引在日常运行中也需要定期执行这种压缩操作。PostgreSQL 提供了 VACUUM 机制来承担此类维护任务,因此该压缩阶段也会在 VACUUM 过程中定期对索引执行。遗憾的是,我们无法在 VACUUM 中将其并行化:PostgreSQL 不允许索引使用嵌套并行(nested parallelism)。如果 VACUUM 本身是并行执行的,索引就无法再次启动并行工作进程。

6. 总结

在此之前,使用 VectorChord 0.5.3 在 Amazon i7i.4xlarge 实例上为 LAION-100M 数据集构建索引是不可行的,因为会发生内存溢出(OOM)错误。将聚类卸载到 GPU 使得索引构建成为可能,在查询 Top 10 结果时,可在 120 QPS 下达到 95.6% 的召回率(recall),但需要在 GPU 上耗时 30 分钟,并在 i7i.4xlarge 上耗时 420 分钟。

凭借 VectorChord 1.0.0 引入的一系列优化,现在完全可以在单台 i7i.4xlarge 实例上用仅仅 18 分钟完成整个索引构建,并在相同 QPS 下达到 94.9% 的召回率。

CREATE INDEX ON laion USING vchordrq (embedding vector_ip_ops) WITH (options = $$
build.pin = 2
[build.internal]
lists = [400, 160000]
build_threads = 16
spherical_centroids = true
kmeans_algorithm.hierarchical = {}
kmeans_dimension = 100
sampling_factor = 64
$$);

我们的目标是让 VectorChord 成为 PostgreSQL 上最优秀的检索方案之一,无论你是在构建第一个原型,还是在应对十亿级规模的数据集。如果你已经在使用 pgvector,我们非常欢迎你在真实工作负载上试用 VectorChord 1.0,并告诉我们它在哪些地方帮到了你,又在哪些地方还可以做得更好。

3 - 借助 VectorChord-BM25 将搜索引擎级排序引入 PostgreSQL

现代应用依赖 PostgreSQL 完全符合 ACID 的事务特性、表达力丰富的 SQL 以及蓬勃发展的扩展生态。在关系型负载之外,VectorChord-BM25 将 BM25 相关性评分算法直接引入了 PostgreSQL。

现代应用普遍依赖 PostgreSQL 完全符合 ACID 的事务特性、表达力丰富的 SQL 以及繁荣的扩展生态系统。虽然 PostgreSQL 在处理关系型负载方面表现得无与伦比,但许多项目同样需要在庞大的文本集合(如商品描述、客服工单、技术文档)中进行搜索,并将最相关的行优先呈现给用户。PostgreSQL 的原生工具为此提供了一定基础,但其默认的排序逻辑往往会让最理想的匹配结果被淹没在不太有用的结果之下。VectorChord‑BM25 通过将 BM25 相关性评分算法直接引入 PostgreSQL,彻底改变了这一局面。

什么是 PostgreSQL?

PostgreSQL 是一款开源的企业级关系型数据库管理系统(RDBMS),以高可靠性、数据完整性和标准合规性而闻名。它支持窗口函数、物化视图、JSONB 存储等高级特性,并拥有强大的扩展机制,允许开发者按需扩展新功能。点击此处了解更多关于 PostgreSQL 的信息。

基于 tsvector 的全文搜索

PostgreSQL 内置了名为 tsvector 的基础文本搜索工具。你可以把它想象成一种特殊的列,以数据库能够快速检索的格式存储每篇文档中的重要词汇(词位,lexemes)。当你在该列上添加 GIN 或 GiST 索引后,PostgreSQL 几乎可以瞬间找到匹配某个关键词的行。你可以使用 @@ 操作符编写搜索查询,完整细节可参阅 PostgreSQL 手册的这一章节。

原生排序的不足之处

虽然 ts_rank 函数可以对匹配结果进行排序,但它的评分方法非常基础。仅提及一次某词汇的长篇文档,其排名可能会高于完全围绕该查询主题展开的短篇文档;而且罕见但重要的关键词也几乎没有额外的权重加成。在大规模数据集中,这往往会导致搜索结果的实际感知相关性大打折扣。

全新突破:VectorChord 引入 BM25

VectorChord‑BM25 是一款轻量级的 PostgreSQL 扩展,它使用行业标准的 BM25 排序公式增强了现有的文本搜索技术栈。你无需将数据导出到外部搜索引擎,而是可以把所有数据留在单一数据库内部,同时获得以下优势:

  • 奖励高区分度词汇并惩罚冗长无关内容的精准排序

  • 索引内评分(In-index scoring),显著降低查询延迟

  • 无缝的 SQL 工作流——文档依然是表中的行,搜索依然是标准的 SQL 查询

什么是 BM25?

BM25(Best Matching 25)是一种被搜索引擎和学术界广泛采用的概率检索模型。它综合评估查询词在文档中出现的频率、该词在整个语料库中的罕见程度,以及每篇文档的长度。关于该算法的详细讨论可参见其维基百科页面,但其核心思想非常直观:

  • 词频(Term Frequency, TF)——某个词在文档中出现的次数越多,相关性越高,但边际收益递减。

  • 逆文档频率(Inverse Document Frequency, IDF)——罕见词比常见词包含更多有效信息。

  • 文档长度归一化(Document Length Normalisation)——聚焦于查询主题的短文档不会因为篇幅短而受到不公平的惩罚。

VectorChord 通过全新的索引类型和操作符(<&>)实现了 BM25,使 PostgreSQL 能够在扫描索引的同时直接计算出相关性得分。


两个实用示例

下面通过两个简明的演示,展示如何结合预训练分词器使用 VectorChord‑BM25,以及如何针对特定专业领域使用自定义模型。

示例 1:使用预训练模型快速上手

# Spin up a pre‑configured PostgreSQL instance
docker run --name vchord-suite \
  -e POSTGRES_PASSWORD=postgres \
  -p 5432:5432 \
  -d tensorchord/vchord-suite:pg17-latest
-- Inside psql
CREATE EXTENSION pg_tokenizer CASCADE;
CREATE EXTENSION vchord_bm25  CASCADE;

-- Register the LLMLingua‑2 tokenizer
SELECT create_tokenizer('llm_tok', $$ model = "llmlingua2" $$);

CREATE TABLE articles (
    id   SERIAL PRIMARY KEY,
    body TEXT,
    emb  bm25vector
);

INSERT INTO articles(body) VALUES
('PostgreSQL is a powerful open‑source database system.'),
('BM25 is a ranking function used by search engines.');

-- Tokenise each row
UPDATE articles SET emb = tokenize(body, 'llm_tok');

-- Build the BM25 index
CREATE INDEX articles_emb_bm25 ON articles USING bm25 (emb bm25_ops);

-- Query with relevance ordering
SELECT id,
       body,
       emb <&> to_bm25query('articles_emb_bm25',
                            tokenize('open source database', 'llm_tok')) AS score
FROM   articles
ORDER  BY score         -- lower (more negative) = higher relevance
LIMIT  10;

该查询直接返回按 BM25 排好序的行,呈现出比默认 ts_rank 更符合直觉的结果。

示例 2:面向领域特定词汇表的自定义模型

特定领域的文本——如医疗记录、法律文书、技术日志——通常包含通用模型中缺失的专业术语。VectorChord 允许你直接在 SQL 中训练自定义分词器。

-- 1. Create a text analyzer with Unicode segmentation, lowercasing,
--    stop‑word removal, and stemming.
SELECT create_text_analyzer('tech_analyzer', $$
pre_tokenizer = "unicode_segmentation"
[[character_filters]]
to_lowercase = {}
[[token_filters]]
stopwords = "nltk_english"
[[token_filters]]
stemmer = "english_porter2"
$$);

-- 2. Train a model on your own corpus and set up automatic embedding
SELECT create_custom_model_tokenizer_and_trigger(
    tokenizer_name     => 'tech_tok',
    model_name         => 'tech_model',
    text_analyzer_name => 'tech_analyzer',
    table_name         => 'tickets',
    source_column      => 'issue_text',
    target_column      => 'embedding');

-- 3. Insert support tickets; embeddings are generated via trigger
INSERT INTO tickets(issue_text)
VALUES ('Kubernetes pod fails with ExitCode 137 after OOM kill.'),
       ('Network latency spikes to 250ms during peak hours.');

-- 4. Build an index and query as before
CREATE INDEX tickets_emb_bm25 ON tickets USING bm25 (embedding bm25_ops);

SELECT issue_text,
       embedding <&> to_bm25query('tickets_emb_bm25',
                                  tokenize('OOM kill ExitCode 137', 'tech_tok')) AS score
FROM   tickets
ORDER  BY score
LIMIT  5;

借助量身定制的词汇表,数据库能够准确识别“OOM”和“Kubernetes”等专有缩写与术语,从而为技术支持工单场景提供更加精准的排序结果。

结语

VectorChord‑BM25 在不引入额外服务层的前提下,将搜索引擎级别的相关性排序带入了 PostgreSQL 生态系统。通过将灵活的分词器、高效的 BM25 索引以及熟悉的 SQL 相结合,它让开发者能够在保持单一数据库系统运维简洁性的同时,交付显著更优的搜索体验。如果你的应用基于 PostgreSQL 构建且需要精准的文本排序,VectorChord‑BM25 绝对值得一试。

4 - VectorChord:在 AWS 上高性价比地上传与搜索 4 亿向量

使用 VectorChord 与 PostgreSQL 上传、构建索引并搜索 4 亿规模向量,包含硬件选型技巧与搜索性能调优建议

在本文中,我们将介绍一种利用真实世界的 LAION-400M 数据集和 PostgreSQL 中的 VectorChord 扩展,以高性价比方式上传、构建索引并搜索大规模向量数据的方法。

我们将向您展示需要什么样的硬件配置,才能使用 VectorChord 在这一海量数据集上进行搜索,并确保您获得既准确又快速的搜索体验。

VectorChord 是一款 PostgreSQL 扩展,能够将高性能向量数据库能力直接引入您的 PostgreSQL 数据库中,并尽可能高度兼容热门的 pgvector 扩展接口。

本教程所使用的 VectorChord 版本必须为 v0.3.0 或更高版本。虽然 VectorChord 兼容大多数近期版本的 pgvector,但我们在测试中主要针对 pgvector 0.8.0 进行了全面验证。

数据集

本实验使用的数据集是 LAION-400M,这是一个包含约 4 亿条图像衍生向量的数据集。每条向量均为 512 维,由 CLIP 模型生成。

向量数据总量约为 400 GB。这些向量已经过归一化处理,因此可以使用多种距离度量方式(L2、余弦距离、点积)。为了与该数据集上的其他基准测试保持一致,并遵循 CLIP 嵌入本身的特性,我们在本实验中采用了余弦距离(cosine distance)度量。

LAION-400M 被划分为 409 个数据分块(chunks),每个分块包含约 100 万条向量。我们将按顺序处理并上传这些分块。

算法

在 4 亿条向量中进行搜索是一项巨大的挑战,尤其是庞大的数据量意味着绝大部分数据必须存储在磁盘而非内存中。我们需要专为这种规模量身定制的算法!处理大规模磁盘向量数据的一种流行且高效的方法是基于图的 DiskANN,它以低内存和低资源开销而闻名。

然而,由于衍生自 HNSW,DiskANN 也继承了一些固有缺点:

  • 索引构建缓慢:对于大规模数据集,众所周知,使用 DiskANN 构建索引往往需要耗费漫长的时间。

  • 插入性能不佳:对于流式插入的向量,更新基于图的索引需要遍历局部图结构。由于 DiskANN 的磁盘图必须加载到内存中,这一操作的代价更加高昂。

VectorChord 通过其 VChordRQ 索引解决了这些问题。VChordRQ 将基于聚类的索引(IVF)与巧妙的数据压缩方案 RaBitQ 结合在一起。除了在内存和磁盘场景下均具备更优的精度和更低的延迟外,RaBitQ 的核心优势还在于它拥有严谨的准确度理论下界保证——这与传统的启发式压缩技术截然不同,帮助我们在准确度与效率之间实现了绝佳的平衡!

提示

有关 VChordRQ(VectorChord)、HNSW(pgvector)与 StreamDiskANN(pgvectorscale)的更多对比,请参阅我们之前的博客文章。

硬件配置

根据我们的实验,以下云实例规格足以在可接受的延迟下完成该数据集的索引构建与查询:

  • 实例类型:AWS EC2 i8g.2xlarge($501.0720 / 月)

  • CPU:8 vCPUs (ARM64)

  • 内存:64GB

  • 存储:1875 GB NVMe SSD

该配置提供了必要的计算能力以及充裕的高速本地 NVMe 存储。64GB 内存则作为缓存以加速搜索查询。

提示

我们使用了配备 1875 GB NVMe 存储的实例,尽管实际只需要约 1014 GB。这是 AWS 上能提供足够高速本地 SSD 的最低规格选项。如果能找到存储空间更贴合的机器,还可以进一步降低成本。

在该实例的 PostgreSQL 配置中,我们调整了 shared_buffers 参数:

  • 在索引构建过程中设置为 48GB。

  • 在搜索评估阶段上调至 54GB,以便缓存更多数据,提升搜索性能。

数据上传与索引构建

VectorChord 的 VChordRQ 索引采用倒排文件(IVF)结构。虽然 VChordRQ 可以在索引构建过程中计算所需的聚类质心(centroids),但面对如此庞大的规模(4 亿向量),在系统外部预先计算好质心能够大幅加快索引构建速度。

针对 LAION-400M,我们在配备 A10 GPU 的实例上执行了外部 k-means 聚类步骤来确定这些质心,以加快计算速度:

  • 聚类配置为 nlist = 160000 个聚类簇。

  • 聚类共执行 25 次迭代,每次迭代耗时约 220 秒,总计约 1.5 小时。

计算出质心后,我们开始部署启用了 VectorChord 的 PostgreSQL 数据库。为了方便起见,我们使用了预构建的 Docker 镜像:

-- Mount PGDATA path to the NVMe SSD
-- sudo mount /dev/nvme1n1 /data
docker run --name vchord-pg17 \
-e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -v /data/pg:/var/lib/postgresql/data \
-d tensorchord/vchord-postgres:pg17-v0.3.0

该 Docker 镜像包含 PostgreSQL 17,并预装了 pgvector 0.8.0 和 VectorChord 0.3.0。

上传流程的核心在于:先将预计算的质心插入到一张专用表中,随后将 LAION 数据集分块中的向量插入主数据表。可以使用 Python 中的 psycopg 等客户端库,通过批量插入(batched inserts)高效完成这一操作。

首先,将质心插入质心表(例如 laion_centroids):

-- The centroids table we created is:
-- CREATE TABLE laion_centroids (id SERIAL PRIMARY KEY, vector vector(512));
INSERT INTO laion_centroids (vector) VALUES ('[...centroid vector...]'); -- Insert all 160000 centroids

接着,将数据集中的向量插入主数据表(例如 laion):

-- The data table we created is:
-- CREATE TABLE laion (id BIGINT PRIMARY KEY, embedding vector(512));
-- Note: VectorChord is compatible with pgvector's vector insert syntax.
INSERT INTO laion (id, embedding) VALUES (0, '[...vector data...]'); -- Batch inserts for chunks

插入所有数据后,我们构建 VectorChord 索引(vchordrq)。vchordrq 代表 RaBitQ,这是 VectorChord 基于 IVF 结构并结合量化与高效重排序打造的高度优化索引类型。

-- Note: The metric operator vector_cosine_ops is specified here, similar to pgvector. 
-- The $$ syntax is used to pass build options as a text block.
CREATE INDEX laion_embedding_idx ON laion USING vchordrq (embedding vector_cosine_ops) WITH (options = $$
[build.external]
table = 'public.laion_centroids' -- Link to the pre-computed centroids
$$);

下图展示了在数据上传和索引构建过程中,实例上的系统资源利用率(CPU、内存和磁盘占用)。磁盘占用曲线展示了数据写入阶段(初期陡增)以及随后的索引构建阶段(平稳增长),最终总占用略超 1TB。CPU 和内存曲线则反映了整个过程中消耗的计算与内存资源。

基准真值数据(Ground Truth)

为了准确衡量搜索性能和精度,与真值数据(ground truth)进行比对至关重要。LAION 数据集本身并未附带现成的真值数据。我们非常感谢 Qdrant 团队公开了他们为该数据集教程所准备的真值数据。

他们的方法是对数据集中的前 100 条向量执行全表扫描最近邻搜索,找出每条查询向量的前 50 个真实最近邻。我们使用了完全相同的真值文件来评估 VectorChord 的搜索精度。

搜索查询

VectorChord 搜索采用两个主要参数来控制性能与精度之间的权衡:nprobe 和 epsilon。

  • nprobe:通过 vchord.probes 参数进行配置。该参数决定了在初始阶段搜索 IVF 索引中的多少个聚类簇(分区)。较高的 nprobe 会扩大搜索范围,有助于找到更优的结果,但也会增加延迟。

  • epsilon:通过 vchord.epsilon 参数进行配置。该参数控制重排序的精度。从探测的 nprobe 个聚类簇中获取初始候选集后,VectorChord 会使用更精确的向量数据执行重排序。较大的 epsilon 意味着会考虑更多候选向量并进行重排序以获得更高的召回率,但同样会增加延迟。

在执行搜索查询之前,将这些参数设置为 PostgreSQL 会话变量:

-- Example settings
SET vchord.probes = 100; -- Configure nprobe
SET vchord.epsilon = 0.8; -- Configure epsilon

-- The search query itself uses the standard pgvector distance operator
SELECT id FROM laion ORDER BY embedding <=> '[...query_vector...]' LIMIT 50;

ORDER BY embedding <=> '[...]' 子句利用索引,根据指定的距离度量(与 CLIP 一致的余弦距离)查找近似最近邻。LIMIT 50 则返回前 50 条结果。

执行搜索请求

索引构建完成后,我们使用不同的 nprobe 和 epsilon 设置,在 100 条真值查询向量上运行了查询,以测试性能与精度之间的权衡关系。

我们以毫秒(ms)为单位表示搜索延迟。“冷缓存(Cold)”延迟是指目标向量数据需要从磁盘读取的情况,而“热缓存(Warm)”延迟则代表数据已经缓存在内存中。

以下是跨 100 条查询的平均延迟以及对应的 Precision@50 得分:

nprob / epsilon 冷缓存延迟 (ms) 热缓存延迟 (ms) Precision@50
10 / 0.8 112.5 7.0 0.8192
100 / 0.8 194.2 15.0 0.9216
200 / 1.0 331.2 24.9 0.9438
400 / 1.5 970.9 51.6 0.9608
800 / 1.9 2174.0 2174.0 0.9662

上表清晰地展示了这种权衡关系:

  • 增大 nprobe 和 epsilon 通常会带来更高的 Precision@50。

  • 然而,这是以增加搜索延迟为代价的,在冷缓存场景下尤为明显——因为需要更多的磁盘 I/O 来从更多聚类簇中读取数据,并执行更广泛的重排序。

  • 热缓存性能明显快得多,凸显了配备足够内存以缓存热点数据的重要性。

  • 在热缓存下,可以在 50ms 以内的延迟下实现超过 90% 的精度;而若要在此基础上进一步追求极致召回率,则会根据所选参数带来数百毫秒甚至超过一秒的延迟。

下图展示了 VectorChord(冷缓存与热缓存)与 Qdrant 在每秒查询数(QPS)和 Precision@50 之间的权衡对比,其中 Qdrant 的数据引自其在同级别 8 vCPU、64 GB 机器上公开发布的基准测试结果,而非在同一台物理机器上重跑。

结论

在本教程中,我们展示了如何将 VectorChord 作为 PostgreSQL 扩展,在配备高速 NVMe SSD 的中小型硬件上,高性价比地上传、构建索引并搜索多达 4 亿条向量的庞大数据集。

通过结合使用 VectorChord 的 VChordRQ 索引与外部质心构建,我们成功从容应对了这一海量数据规模。在查询时合理设置 nprobe 和 epsilon,可以精确控制搜索速度与准确度之间的关键权衡,让用户能够根据自身业务需求灵活调优系统。

VectorChord 对 pgvector 接口的良好兼容性降低了现有 pgvector 用户的迁移门槛,而其 vchordrq 索引则在稳健、熟悉的 PostgreSQL 环境中直接提供了强大的大规模向量搜索能力。

5 - 在 PostgreSQL 中利用上下文检索优化 RAG

在 PostgreSQL 中结合向量搜索与关键词 BM25 评分实现上下文检索(Contextual Retrieval),全面提升大语言模型的生成质量。

在标准的检索增强生成(RAG)方案中,一种常见做法是将长篇文档切分为更小、更易于管理的文本块(chunk)。这样做主要是为了结合句子嵌入(sentence embedding)与关键词 BM25 评分来提升检索效率,使系统能够快速定位并提取出相关的信息片段。虽然这种方法在许多场景下行之有效,但它也带来了一个显著的挑战:单个文本块很可能会丢失其原本所处的上下文语境。

不妨以历史文献集合为例。如果用户询问某个特定历史事件,而检索出的文本块写着:“条约正式签署,敌对状态宣告结束。”

脱离了前后的文本,这段分块就缺失了至关重要的背景信息:签署的是哪一份条约、缔约双方是谁,以及这一重大事件发生在何时何地。如果仅依赖这样一个脱离上下文的片段,提供给大语言模型(LLM)的信息将是残缺甚至可能产生误导的,从而妨碍其针对该历史事件生成全面、准确的回答。

这个例子清楚地说明:虽然分块(chunking)有助于提升检索效率,但它可能会无意间剥离赋予信息片段具体含义和相关性的必要上下文。这种上下文缺失会严重影响 RAG 系统中 LLM 生成回答的质量与准确性。

为了解决这一问题,Anthropic 探索并提出了一种名为上下文检索(Contextual Retrieval)的方法。

简而言之,可以将它视为利用整篇文档的上下文对分块进行增强(chunk augmentation)。

本文将带你一步步借助上下文信息来提升检索准确率。我们使用了与 Anthropic 教程中相同的提示词(prompt)、数据集和评估指标。需要注意的是,我们在本次实验中使用了不同的嵌入模型和 LLM,因此具体数值会有所不同;但这并不反映模型本身的优劣,因为该数据集主要由代码而非自然语言构成。

我们主要使用 Gemini 模型来生成分块上下文和文本嵌入,其他模型也同样适用。嵌入质量对向量检索指标影响很大,建议参考 MTEB 排行榜挑选最契合你业务场景的模型。在关键词分词方面,我们使用默认的 BERT 分词器,你也可以尝试其他分词方案。

重排序(rerank)并非本文的重点;如果你想深入了解重排序方法,欢迎阅读我们关于 Cross-Encoder 重排序以及词元级延迟交互重排序的专题文章。顺便提一句,由于我们已经支持多向量 MaxSim 索引,词元级延迟交互重排序现在可以更加高效地执行。

准备环境

我们在 VectorChord-suite Docker 镜像中同时提供了向量搜索和关键词搜索功能。

docker run --rm -d --name vdb -e POSTGRES_PASSWORD=postgres -p 5432:5432 ghcr.io/tensorchord/vchord-suite:pg17-20250414

我们将使用 vechord Python 库来减少样板代码并简化开发流程。

pip install 'vechord[gemini]'

让我们先从基础的 RAG 流程开始。

from vechord.spec import (
    ForeignKey,
    Keyword,
    PrimaryKeyAutoIncrease,
    Table,
    UniqueIndex,
    Vector,
)

DenseVector = Vector[768]

class Document(Table, kw_only=True):
    uid: Optional[PrimaryKeyAutoIncrease] = None
    uuid: Annotated[str, UniqueIndex()]
    content: str

class Chunk(Table, kw_only=True):
    uid: Optional[PrimaryKeyAutoIncrease] = None
    doc_uuid: Annotated[str, ForeignKey[Document.uuid]]
    index: int
    content: str
    vector: DenseVector
    keyword: Keyword

class Query(Table, kw_only=True):
    uid: Optional[PrimaryKeyAutoIncrease] = None
    content: str
    answer: str
    doc_uuids: list[str]
    chunk_index: list[int]
    vector: DenseVector

上述定义已经自动包含了向量嵌入索引和关键词索引。

加载数据集的代码如下:

from vechord.embedding import GeminiDenseEmbedding
from vechord.registry import VechordRegistry

emb = GeminiDenseEmbedding()
vr = VechordRegistry("anthropic", "postgresql://postgres:[email protected]:5432/")

vr.register([Document, Chunk, Query])

def load_data(filepath: str):
    with open(filepath, "r", encoding="utf-8") as f:
        docs = json.load(f)
        for doc in docs:
            vr.insert(
                Document(
                    uuid=doc["original_uuid"],
                    content=doc["content"],
                )
            )
            for chunk in doc["chunks"]:
                vr.insert(
                    Chunk(
                        doc_uuid=doc["original_uuid"],
                        index=chunk["original_index"],
                        content=chunk["content"],
                        vector=emb.vectorize_chunk(chunk["content"]),
                        keyword=Keyword(chunk["content"]),
                    )
                )

def load_query(filepath: str):
    queries = []
    with open(filepath, "r", encoding="utf-8") as f:
        for line in f:
            query = json.loads(line)
            queries.append(
                Query(
                    content=query["query"],
                    answer=query["answer"],
                    doc_uuids=[x[0] for x in query["golden_chunk_uuids"]],
                    chunk_index=[x[1] for x in query["golden_chunk_uuids"]],
                    vector=emb.vectorize_query(query["query"]),
                )
            )
    vr.copy_bulk(queries)

评估

现在我们已经具备了基础 RAG 流程所需的全部组件。接下来定义评估指标 Pass@k,它表示检索出的 Top-K 文本块中包含了多少比例的标准答案(ground truth)文本块。

def evaluate(topk=5, search_func=vector_search):
    print(f"TopK={topk}, search by: {search_func.__name__}")
    queries: list[Query] = vr.select_by(Query.partial_init())
    total_score = 0
    start = perf_counter()
    for query in queries:
        chunks: list[Chunk] = search_func(query, topk)
        count = 0
        for doc_uuid, chunk_index in zip(
            query.doc_uuids, query.chunk_index, strict=True
        ):
            for chunk in chunks:
                if chunk.doc_uuid == doc_uuid and chunk.index == chunk_index:
                    count += 1
                    break
        score = count / len(query.doc_uuids)
        total_score += score

    print(
        f"Pass@{topk}: {total_score / len(queries):.4f}, total queries: {len(queries)}, QPS: {len(queries) / (perf_counter() - start):.3f}"
    )

我们可以尝试不同的检索策略,例如向量搜索、关键词搜索、结合融合(fusion)或重排序(rerank)的混合搜索。

这些检索策略可定义如下:

from vechord.rerank import CohereReranker, ReciprocalRankFusion

def vector_search(query: Query, topk: int) -> list[Chunk]:
    return vr.search_by_vector(Chunk, query.vector, topk=topk)

def keyword_search(query: Query, topk: int) -> list[Chunk]:
    return vr.search_by_keyword(Chunk, query.content, topk=topk)

def hybrid_search_fuse(query: Query, topk: int) -> list[Chunk]:
    rrf = ReciprocalRankFusion()
    return rrf.fuse([vector_search(query, topk), keyword_search(query, topk)])[:topk]

def hybrid_search_rerank(query: Query, topk: int, boost=3) -> list[Chunk]:
    ranker = CohereReranker()
    vecs = vector_search(query, topk * boost)
    keys = keyword_search(query, topk * boost)
    chunks = list({chunk.uid: chunk for chunk in vecs + keys}.values())
    indices = ranker.rerank(query.content, [chunk.content for chunk in chunks])
    return [chunks[i] for i in indices[:topk]]

上下文检索(Contextual Retrieval)

LLM 使用如下提示词生成上下文信息:

prompt = (
    "<document>\n{whole_document}\n</document>"
    "Here is the chunk we want to situate within the whole document \n"
    "<chunk>\n{chunk}\n</chunk>\n"
    "Please give a short succinct context to situate this chunk within "
    "the overall document for the purposes of improving search retrieval "
    "of the chunk. Answer only with the succinct context and nothing else."
)

上面的提示词是专为通用检索任务设计的。针对你的具体业务场景,定制更专门的提示词往往能取得更好的效果。

这一功能已经内置在 vechord 库中。

GeminiAugmenter 同样支持类似 Claude 的提示词缓存(prompt caching)功能,但它要求缓存内容至少达到 32768 个 token,而本实验所用的数据集未达到该长度阈值。

from vechord.augment import GeminiAugmenter

class ContextualChunk(Table, kw_only=True):
    uid: Optional[PrimaryKeyAutoIncrease] = None
    doc_uuid: Annotated[str, ForeignKey[Document.uuid]]
    index: int
    content: str
    context: str
    vector: DenseVector
    keyword: Keyword

def load_contextual_chunks(filepath: str):
    augmenter = GeminiAugmenter()

    with open(filepath, "r", encoding="utf-8") as f:
        docs = json.load(f)
        for doc in docs:
            augmenter.reset(doc["content"])
            chunks = doc["chunks"]
            augments = augmenter.augment_context([chunk["content"] for chunk in chunks])
            if len(augments) != len(chunks):
                print(f"augments length not match for uuid: {doc['original_uuid']}, {len(augments)} != {len(chunks)}")
            for chunk, context in zip(chunks, augments, strict=False):
                contextual_content = f"{chunk['content']}\n\n{context}"
                vr.insert(
                    ContextualChunk(
                        doc_uuid=doc["original_uuid"],
                        index=chunk["original_index"],
                        content=chunk["content"],
                        context=context,
                        vector=emb.vectorize_chunk(contextual_content),
                        keyword=Keyword(contextual_content),
                    )
                )

随后,我们可以将检索策略扩展为如下形式:

def vector_contextual_search(query: Query, topk: int) -> list[ContextualChunk]:
    return vr.search_by_vector(ContextualChunk, query.vector, topk=topk)

def keyword_contextual_search(query: Query, topk: int) -> list[ContextualChunk]:
    return vr.search_by_keyword(ContextualChunk, query.content, topk=topk)

def hybrid_contextual_search_fuse(query: Query, topk: int) -> list[ContextualChunk]:
    rrf = ReciprocalRankFusion()
    return rrf.fuse(
        [vector_contextual_search(query, topk), keyword_contextual_search(query, topk)]
    )[:topk]

def hybrid_contextual_search_rerank(
    query: Query, topk: int, boost=3
) -> list[ContextualChunk]:
    ranker = CohereReranker()
    vecs = vector_contextual_search(query, topk * boost)
    keys = keyword_contextual_search(query, topk * boost)
    chunks = list({chunk.uid: chunk for chunk in vecs + keys}.values())
    indices = ranker.rerank(
        query.content, [f"{chunk.content}\n{chunk.context}" for chunk in chunks]
    )
    return [chunks[i] for i in indices[:topk]]

基准测试结果

  • topk=5
Pass_at_5 QPS
向量搜索(vector search) 0.8071 289.024
关键词搜索(keyword search) 0.7003 337.211
上下文向量搜索(vector contextual search) 0.8404 307.253
上下文关键词搜索(keyword contextual search) 0.8033 331.239
  • topk=10
Pass_at_10 QPS
向量搜索(vector search) 0.8574 254.730
关键词搜索(keyword search) 0.7577 219.653
上下文向量搜索(vector contextual search) 0.8807 247.819
上下文关键词搜索(keyword contextual search) 0.8563 216.560

所有代码均可在我们的 vechord 仓库中找到。

6 - 一站式 VectorChord 套件:直接在 PostgreSQL 中构建生产就绪的 RAG 解决方案

利用 VectorChord 套件的向量搜索、BM25 排序和灵活分词扩展,直接在 PostgreSQL 中构建生产就绪的 RAG 解决方案

检索增强生成(Retrieval-Augmented Generation, RAG)正在彻底改变我们与海量数据集及语言模型的交互方式。通过在生成回答之前先检索相关信息,RAG 系统能够提供更准确、更具上下文感知能力且实时更新的答案。然而,构建稳健、可扩展且高效的 RAG 流水线依然面临诸多挑战,在生产环境中更是如此。

许多解决方案采用了复杂的架构,分别为向量搜索、关键词搜索和主数据存储使用不同的数据库。这通常会导致数据同步难题、更高的基础设施复杂度以及高昂的运维成本。

如果可以直接在你信赖的 PostgreSQL 数据库内部构建一套强大且生产就绪的 RAG 系统,会怎样呢?

为此,我们推出了 VectorChord 套件(VectorChord Suite)——这是一组旨在将高性能向量搜索、原生 BM25 排序以及灵活的分词能力直接引入 Postgres 的 PostgreSQL 扩展集合。下面让我们一起探索它的各个组件,看看它们如何赋能下一代 RAG 解决方案。

什么是 VectorChord 套件?

该套件由三个协同工作的核心 PostgreSQL 扩展组成:

  1. VectorChord:核心向量搜索引擎。专为在 PostgreSQL 内部实现可扩展、高性能且磁盘高效的向量相似度搜索而设计。

  2. VectorChord-bm25:该扩展利用高效的 Block-WeakAnd 算法,直接在 PostgreSQL 内部实现了成熟的 BM25 排序算法。BM25 是基于关键词频率和文档特征进行相关性排序的行业标准。

  3. pg_tokenizer.rs:提供高效全文搜索所必需的文本分词(Tokenization)能力,支持对全文搜索的文本处理方式进行细粒度控制。

通过组合这些扩展,你可以完全在 PostgreSQL 内部解锁构建高级 RAG 系统的强大能力。

如何使用 VectorChord 套件

你可以使用 tensorchord/vchord-suite 镜像来运行 TensorChord 提供的多个扩展。该镜像基于官方 Postgres 镜像构建,包含以下扩展:

docker run   \           
  --name vchord-suite  \
  -e POSTGRES_PASSWORD=postgres  \
  -p 5432:5432 \
  -d tensorchord/vchord-suite:pg17-latest
  # If you want to use ghcr image, you can change the image to `ghcr.io/tensorchord/vchord-suite:pg17-latest`.
  # if you want to use the specific version, you can use the tag `pg17-20250414`, supported version can be found in the support matrix.
CREATE EXTENSION IF NOT EXISTS vchord CASCADE;
CREATE EXTENSION IF NOT EXISTS pg_tokenizer CASCADE;
CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;
\dx
pg_tokenizer | 0.1.0   | tokenizer_catalog | pg_tokenizer
vchord       | 0.3.0   | public            | vchord: Vector database plugin for Postgres, written in Rust, specifically designed for LLM
vchord_bm25  | 0.2.0   | bm25_catalog      | vchord_bm25: A postgresql extension for bm25 ranking algorithm
vector       | 0.8.0   | public            | vector data type and ivfflat and hnsw access methods

VectorChord 套件的应用场景

场景 1:结合原生 BM25 与 VectorChord 的强大混合搜索

在 RAG 时代,高效的检索至关重要。无论是单纯的关键词搜索还是单纯的向量搜索,都并非十全十美:

  • 关键词搜索(如 BM25):擅长精准匹配,能够找出包含精确关键词的文档,非常适合结构化查询和特定术语搜索。但它在处理同义词、改写表达以及理解深层含义或语义意图方面表现欠佳。(利用 VectorChord-bm25 和 pg_tokenizer)。

  • 向量搜索:擅长捕捉深层语义和概念之间的关联,即使文档中没有出现完全一致的关键词,也能找到相关信息。但在面对要求精确匹配特定术语的查询时,有时会缺乏精准度。(利用 VectorChord)。

解决方案:混合搜索(Hybrid Search)。通过在 Postgres 内部结合这两种方法的优势,VectorChord 套件弥合了这一差距。你可以运行一条同时利用以下两者的查询:

  • 使用 VectorChord-bm25(由 pg_tokenizer 驱动)保证关键词精准匹配。

  • 使用 VectorChord 实现深层语义理解。

两者的检索结果可以进行智能融合(例如使用倒数排序融合 RRF 或基于模型的重排序 Rerank),从而生成比任何单一方法都更准确、上下文更相关且更具语义感知能力的最终排序。这将为你的 RAG 应用带来显著更优的检索表现。

场景 2:超越纯文本——基于 ColQwen2 与 VectorChord 的免 OCR RAG

为 PDF 或扫描图像等文档构建 RAG 系统,通常涉及繁琐的预处理流水线。传统方法高度依赖光学字符识别(OCR)和版面分析来提取文本。这一过程往往存在以下问题:

  • 速度慢:OCR 的计算开销可能非常高昂。

  • 容易出错:OCR 的准确率受文档质量影响很大。

  • 信息有损:在提取纯文本的过程中,表格、图表、格式以及相对位置等关键视觉上下文信息往往会丢失。

解决方案:免 OCR、具备视觉感知能力的 RAG。如果可以直接根据文档的外观版式及其内容进行查询,而无需显式执行 OCR,会怎样呢?

现在,通过结合以下两项技术即可实现这一目标:

  1. 多模态视觉语言模型(VLM):像 ColQwen2 这样的模型可以直接处理图像(文档页面),并生成同时捕捉文本内容和视觉版面信息的向量嵌入。

  2. VectorChord 的多向量能力:VectorChord 可以在 Postgres 内部高效地为每篇文档存储和搜索多个向量——允许你存储代表不同维度特征(如文本内容、视觉布局)的嵌入向量。

借助这套方案,你可以直接在 PostgreSQL 中使用引用视觉元素的提示词(例如“查找包含对比销售数据的柱状图的文档”)或结合文本与视觉线索的查询来检索文档数据库。这不仅简化了你的 RAG 技术栈,通过保留视觉上下文提升了检索准确率,还彻底消除了传统 OCR 流水线带来的性能瓶颈。

为什么要在 PostgreSQL 中构建 RAG?

在 Postgres 内部使用 VectorChord 套件具有诸多显著优势:

  • 数据统一:将源数据、文本、元数据和向量嵌入全部保存在同一个地方。

  • 降低复杂度:无需再维护独立的向量数据库和数据同步流水线。

  • 复用现有基础设施:充分利用你现有的 Postgres 专业知识、工具链和运维实践。

  • 事务完整性:受益于 PostgreSQL 强大的 ACID 事务保障。

  • 丰富的生态系统:无缝使用 Postgres 生态系统中极其丰富的工具与功能特性。

总结

VectorChord 套件将 PostgreSQL 打造成了一个强大且生产就绪的高级 RAG 解决方案构建平台。通过集成高性能向量搜索(VectorChord)、成熟的关键词排序(VectorChord-bm25)以及灵活的文本处理(pg_tokenizer),你可以直接在数据库内部实现混合搜索和免 OCR 多模态检索等前沿技术。立即在 PostgreSQL 中使用 VectorChord,简化系统架构、提升检索精度,充分释放 RAG 的全部潜力。

往期博客文章

在此前的博客中,我们分享过一些实际应用案例,其中的所有镜像现在都可以直接替换为 VectorChord Suite 镜像:

参考资料

7 - 推出 Vechord:以 Pythonic 的方式将 PostgreSQL 打造成你的搜索引擎

借助支持无缝向量与关键词搜索的 Python 库 Vechord,将 PostgreSQL 打造成强大的搜索引擎

今天,我们非常激动地宣布发布 Vechord——一个全新的 Python 库,旨在大幅简化直接在 PostgreSQL 数据库之上构建稳健搜索基础设施的流程。

在人工智能与大语言模型(LLM)飞速发展的今天,检索增强生成(RAG)和语义搜索已成为不可或缺的核心组件。然而,搭建所需的向量搜索基础设施往往意味着学习全新的数据库技术、管理复杂的系统集成,或是与繁琐的框架搏斗。这不仅增加了研发摩擦,还会拖慢开发进度,对于已经熟悉 PostgreSQL 的团队而言尤为如此。

挑战:混合搜索的复杂性

构建搜索能力通常意味着:

  1. 选型与运维专用数据库:评估、部署和维护专用的向量数据库(如 Pinecone、Weaviate、Milvus 等)以及全文检索框架(如 ElasticSearch、Solr 等)会带来额外的运维开销。

  2. 复杂的数据处理:需要管理源数据与向量表征之间的同步。

  3. 陡峭的学习曲线:需要理解那些面向广泛 LLM 任务的庞大框架中的各种 API 与抽象概念。

Vechord:构建于 PostgreSQL 之上的简洁、Pythonic 解决方案

Vechord 通过充分发挥 PostgreSQL 的强大能力与可扩展性,并结合强大的 VectorChord 和 VectorChord-bm25 扩展,正面解决了这些挑战。我们的核心设计理念是简洁与专注。

Vechord 提供了一套干净、Pythonic 的接口,助你轻松完成:

  • 初始化(Initialize):使用 Python 结构体和类型注解轻松配置表结构(Schema)。

  • 数据写入(Ingest Data):配合转换工具,轻松导入文档、PDF 或任何其他类型的数据。

  • 执行混合搜索(Perform Hybrid Search):通过友好的 API 高效执行向量相似度搜索与关键词搜索,并对检索结果进行重排序(rerank)。

  • 指标评估(Evaluate Metrics):无论是对比标准答案(ground truth)还是使用基于 LLM 的评分,都能无缝评估检索指标。

  • 化繁为简(Makes Simple Tasks Simple):提供类 ORM 的接口,方便地对 PostgreSQL 数据库执行查询、插入和删除操作。

Vechord 有何不同?

  1. 高度专注于 PostgreSQL 向量 + 关键词搜索:Vechord 专门致力于让 PostgreSQL + VectorChord 套件的组合在搜索场景下简单易用。如果你的首要目标是在现有的 PostgreSQL 生态中实现高效的向量搜索与关键词搜索,Vechord 提供了一条更精简、更直接的路径。

  2. 定位为库,而非庞大平台:Vechord 被设计为一个库(Library)——一个可以集成到你应用代码中的专注构建块。它为你提供基于 PostgreSQL 的核心存储与混合搜索能力,而将更宏观的应用架构与工作流设计完全交还给你。

  3. 复用现有基础设施:Vechord 的核心前提是赋能已经在使用 PostgreSQL 的团队。如果你的数据规模和业务需求能够通过 VectorChord 套件得到良好满足,就无需再引入和维护一套独立的专用向量数据库或文档数据库,从而大幅降低运维复杂度和成本。

  4. 把简洁当作核心特性:Vechord 优先追求极简的 API 表面积以及特定任务下的易用性,旨在让你以最少的样板代码和心智负担在 Postgres 上实现混合搜索。

快速上手 Vechord

  • 定义表结构(Schema)
from typing import Annotated, Optional
from vechord.spec import Table, Vector, PrimaryKeyAutoIncrease, ForeignKey, Keyword

# use 768 dimension vector
DenseVector = Vector[768]

class Document(Table, kw_only=True):
    uid: Optional[PrimaryKeyAutoIncrease] = None  # auto-increase id, no need to set
    link: str = ""
    text: str

class Chunk(Table, kw_only=True)
    uid: Optional[PrimaryKeyAutoIncrease] = None
    doc_id: Annotated[int, ForeignKey[Document.uid]]  # reference to `Document.uid` on DELETE CASCADE
    vec: DenseVector  # this comes with a default vector index
    keyword: Keyword  # this comes with a default tokenizer and text index
    text: str
  • 使用 Python 装饰器注入数据
import httpx
from vechord.registry import VechordRegistry
from vechord.extract import SimpleExtractor
from vechord.embedding import GeminiDenseEmbedding

vr = VechordRegistry(namespace="test", url="postgresql://postgres:[email protected]:5432/")
# ensure the table and index are created if not exists
vr.register([Document, Chunk])
extractor = SimpleExtractor()
emb = GeminiDenseEmbedding()

@vr.inject(output=Document)  # dump to the `Document` table
# function parameters are free to define since `inject(input=...)` is not set
def add_document(url: str) -> Document:  # the return type is `Document`
    with httpx.Client() as client:
        resp = client.get(url)
        text = extractor.extract_html(resp.text)
        return Document(link=url, text=text)

@vr.inject(input=Document, output=Chunk)  # load from the `Document` table and dump to the `Chunk` table
# function parameters are the attributes of the `Document` table, only defined attributes
# will be loaded from the `Document` table
def add_chunk(uid: int, text: str) -> list[Chunk]:  # the return type is `list[Chunk]`
    chunks = text.split("\n")
    return [Chunk(doc_id=uid, vec=emb.vectorize_chunk(t), keyword=Keyword(t), text=t) for t in chunks]

if __name__ == "__main__":
    add_document("https://paulgraham.com/best.html")  # add arguments as usual
    add_chunk()  # omit the arguments since the `input` is will be loaded from the `Document` table
    vr.insert(Document(text="hello world"))  # insert manually
    print(vr.select_by(Document.partial_init()))  # select all the columns from table `Document`
  • 在单个事务中运行多个步骤以保证数据一致性
pipeline = vr.create_pipeline([add_document, add_chunk])
pipeline.run("https://paulgraham.com/best.html")  # only accept the arguments for the first function
  • 按向量和关键词进行检索,并使用交叉编码器(Cross-Encoder)模型重排序
from vechord.rerank import CohereReranker

reranker = CohereReranker()
text = vr.search_by_vector(Chunk, emb.vectorize_query("startup"))
vec = vr.search_by_keyword(Chunk, "startup")
chunks = list({chunk.uid: chunk for chunk in text_retrieves + vec_retrievse}.values())
indices = reranker.rerank(query, [chunk.text for chunk in chunks])
print([chunks[i] for i in indices[:topk]])

加入社区!

Vechord 是开源且由社区驱动的项目。我们相信它为那些希望获得强大搜索能力、却不想引入不必要复杂度的开发者填补了一个关键空白。

%[https://github.com/tensorchord/vechord]

8 - 超越文本:使用 Modal 与 VectorChord 在 PostgreSQL 中解锁免 OCR 的 RAG

为文档构建高效的检索增强生成(RAG)系统,往往让人感觉像是在与杂乱复杂的流水线搏斗。尤其是在处理 PDF 或扫描图像时……

为文档构建高效的检索增强生成(RAG)系统,往往让人感觉像是在与杂乱复杂的流水线搏斗。尤其是在处理 PDF 或扫描图像时,传统方法高度依赖光学字符识别(OCR)和版面分析。这些步骤不仅耗时、容易出错,而且经常会丢失表格、图表和排版格式等至关重要的视觉上下文信息。但如果可以直接根据文档的“视觉外观”进行查询,而不仅仅依赖提取出来的纯文本呢?

本文将手把手带你构建这样一个系统:直接在你熟悉的 PostgreSQL 数据库中构建一套免 OCR 的 RAG 系统。我们将结合 ColQwen2 视觉语言模型(VLM)的强大理解力、VectorChord 在 Postgres 中进行多向量搜索的高效性,以及 Modal 用于 GPU 加速向量生成的弹性扩展能力。准备好简化你的 RAG 技术栈并大幅提升检索精度吧——这一切都无需复杂的预处理流水线。

本文将涵盖以下内容:

  • 什么是 ColQwen2,以及为什么它是改变游戏规则的利器。

  • VectorChord 如何让高级向量搜索在 Postgres 中成为可能。

  • 从零构建并评估该系统的分步实战教程。

什么是 ColQwen2?视觉理解的力量

要理解 ColQwen2,我们先来看看它的基础:ColPali。正如论文《ColPali: Efficient Document Retrieval with Vision Language Models》中所介绍的,ColPali 代表了一种利用视觉语言模型(VLM)的全新范式。它不再依赖不完美的 OCR,而是直接利用文档丰富的视觉特征——文本、图像、表格、版面布局,乃至肉眼可见的一切元素——来建立索引。

回想一下传统 OCR 的局限性:复杂的版面被拆得七零八落,表格变成一堆乱码,而图像则往往被完全忽略。这就像试图仅通过阅读一份错漏百出的速记稿来理解整本书。ColPali 则避开了这一陷阱,它使用强大的 VLM(最初为 PaliGemma)生成能够捕捉文档整体视觉特征的嵌入向量。有两大核心理念使其脱颖而出:

  1. 融合上下文的视觉嵌入(Contextualized Vision Embeddings):使用 VLM 直接从文档图像生成信息丰富的嵌入向量。

  2. 晚期交互(Late Interaction):这一巧妙的技术允许查询的文本语义在搜索时直接与文档的精细视觉特征进行交互。它不仅仅是匹配文本摘要,而是将查询概念与文档页面内的视觉证据逐一比对。

ColPali 架构示意图(图片源自 ColPali 论文)

ColQwen2 建立在强大的 ColPali 架构之上,但将底层 VLM 替换为了更新的 Qwen2-VL-2B。它生成 ColBERT 风格的多向量表示,能够同时从文本和图像中捕捉细粒度细节。正如在 vidore-leaderboard 榜单上所看到的那样,ColQwen2 以非常实用的模型体积实现了令人惊艳的检索性能。

VectorChord 如何在 Postgres 中赋能 ColQwen2?

这正是 VectorChord 成为关键拼图的地方——它将这一前沿的 VLM 能力直接引入了你的 PostgreSQL 数据库。ColQwen2(以及 ColPali)高度依赖多向量表示和晚期交互机制,特别是需要高效的 **MaxSim(最大相似度)**运算支持。

计算 MaxSim——即找出查询向量集合中的任意向量与文档向量集合中的任意向量之间的最高相似度得分——在计算上极其繁重,当面对数百万个文档向量时更是如此。VectorChord 正面攻克了这一难题:

  • 原生多向量支持:从底层设计之初就支持在 Postgres 内部高效处理多向量数据。

  • 深度优化的 MaxSim:受 WARP 论文启发,VectorChord 采用动态相似度插补(dynamic similarity imputation)等技术大幅加速 MaxSim 计算,使大规模视觉文档检索真正切实可行。

  • 支持混合搜索:除了多向量检索,它还支持稠密向量、稀疏向量和混合搜索(欢迎查看我们的上一篇文章!)。

  • 可扩展且对磁盘友好:专为高性能设计,无需消耗过多的内存与硬件资源。

简而言之,VectorChord 将 PostgreSQL 变成了一个强大的引擎,能够轻松应对 ColQwen2 或 ColPali 等模型所需的高级向量搜索技术。

实战教程:构建你的免 OCR RAG 系统

理论已经讲得很清楚了,现在让我们挽起袖子亲手构建这个系统!我们将依次完成环境搭建、使用 Modal 进行可扩展的向量嵌入生成、将数据索引到 Postgres 内的 VectorChord 中,最后对我们全新的免 OCR RAG 系统进行评估。

前提条件

在开始之前,请确保你已具备:

  • 一个安装了 VectorChord 扩展的 PostgreSQL 实例(推荐使用 Docker)或一个 VectorChord Cloud 集群。

  • 一个 Modal 账号(提供免费额度)。Modal 快速的 GPU 调配与弹性扩缩容能力非常适合生成向量嵌入这一步骤。为了使用 ColQwen2 模型高效处理海量文档,充分利用 Modal 的快速启动和 GPU 扩展特性至关重要,这将大幅缩短本地处理所需的时间。

如果你想快速复现本教程,可以使用 tensorchord/vchord-suite 镜像来运行 TensorChord 提供的多个扩展。

运行以下命令即可构建并启动包含 VectorChord-BM25 和 VectorChord 的 Postgres:

docker run   \           
  --name vchord-suite  \
  -e POSTGRES_PASSWORD=postgres  \
  -p 5432:5432 \
  -d tensorchord/vchord-suite:pg17-latest
CREATE EXTENSION IF NOT EXISTS vchord CASCADE;
CREATE EXTENSION IF NOT EXISTS pg_tokenizer CASCADE;
CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;
\dx
pg_tokenizer | 0.1.0   | tokenizer_catalog | pg_tokenizer
vchord       | 0.3.0   | public            | vchord: Vector database plugin for Postgres, written in Rust, specifically designed for LLM
vchord_bm25  | 0.2.0   | bm25_catalog      | vchord_bm25: A postgresql extension for bm25 ranking algorithm
vector       | 0.8.0   | public            | vector data type and ivfflat and hnsw access methods

配置 Modal:

$ pip install modal
$ python3 -m modal setup  # click the link to authorize

步骤 1:加载数据(使用 Modal Volumes)

我们将使用 ViDoRe Benchmark 数据集。为了在可能分布式运行的 Modal 函数之间高效处理这些数据,我们将其下载到一个 Modal Volume 中。Volume 提供了持久化的共享存储,非常适合这种“一次下载,多次处理”的场景。

image = modal.Image.debian_slim().pip_install("datasets","huggingface_hub","Pillow")
DATASET_DIR = "/data"
DATASET_VOLUME  = modal.Volume.from_name(
    "colpali-dataset", create_if_missing=True
)
app = modal.App(image=image)

@app.function(volumes={DATASET_DIR: DATASET_VOLUME}, timeout=3000)
def download_dataset(cache=False) -> None:
    from datasets import load_dataset
    from tqdm import tqdm
    
    collection_dataset_names = get_collection_dataset_names("vidore/vidore-benchmark-667173f98e70a1c0fa4db00d") 
    for dataset_name in tqdm(collection_dataset_names, desc="vidore benchmark dataset(s)"):
        dataset = load_dataset(dataset_name, split="test",num_proc=10)
        unique_indices = dataset.to_pandas().drop_duplicates(subset="image_filename", keep="first").index #to remove repeating PDF pages with different queries
        dataset = dataset.select(unique_indices)
        dataset.save_to_disk(f"{DATASET_DIR}/{dataset_name}")

Modal 提供了直观的 Python API 来与其平台交互。通过 modal.Image,你可以为 Modal App 设置基础镜像,而 function 装饰器则用于定义要在 Modal 应用中执行的函数。要将数据集下载到 Modal Volume,请执行以下命令。Modal 会自动构建 Docker 镜像并立即启动容器来运行你的函数。

运行下载函数:

$ modal run dataset.py::download_dataset

Modal 会自动在云端完成环境构建和脚本运行。

步骤 2:处理数据并生成向量嵌入(使用 Modal 与 ColQwen2)

这是计算最繁重的部分:将文档图像转换为 ColQwen2 多向量嵌入。如果在本地对大量文档执行此操作会非常缓慢。Modal 在这里展现了强大的优势:

  • 便捷的 GPU 访问与自动扩缩容:在 Modal 上启动一个 ColQwen2 模型服务,并用它为数据集生成图像嵌入和查询嵌入。之所以选择实现 HTTP 服务而不是直接调用 SDK,是为了确保在大规模向量生成期间能够实现无缝的自动扩缩容(Auto-scaling)。如果你想将该嵌入服务部署为持久化的 Web 端点,可以直接将 ColPaliServer 的 modal.cls 改为 modal.web_server 并运行 modal deploy。

  • 断点恢复:我们将处理进度保存(Checkpoint)到另一个独立的 Modal Volume 中,以便在任务中断时能够从断点处继续生成向量。

# embedding.py (Illustrative - keep original code)
# ... imports, volumes setup ...
modal_app = modal.App() # Define app

# Function to coordinate embedding generation
@modal_app.function(...)
def embed_dataset(down_scale: float = 1.0, batch_size: int = BATCH_SIZE):
    # ... (logic for loading dataset names, handling checkpoints) ...
    colpali_server = ColPaliServer() # Get handle to our GPU class
    # ... (loop through datasets, batch items, call server for embeddings) ...
    # ... (save embeddings and update checkpoint) ...
    print("Embedding generation complete.")

# server.py (Illustrative - keep original code)
# Class running on GPU to serve embedding requests
@modal_app.cls(gpu=GPU_CONFIG, ...)
class ColPaliServer:
    @modal.enter()
    def load_model_and_start_server(self):
        # ... (Load ColQwen2 model, start internal FastAPI server) ...
        self.client = httpx.AsyncClient(...) # Client to talk to internal server

    @modal.exit()
    def shutdown_server(self):
        # ... (Cleanup) ...

    # Method called by embed_dataset function
    @modal.method()
    async def embed_images(self, images: List[str]) -> np.ndarray:
        # ... (Prepare batch, send request to internal server via self.client) ...
        # ... (Decode response using msgspec for speed) ...
        return embeddings_numpy_array

    # ... (Potentially add embed_queries method too) ...

# colpali.py
class ColPaliModel:
    def __init__(self, model_name: str = "vidore/colqwen2-v1.0", cache_dir: str="/model"):
        # ...
        if self.model_name == "vidore/colqwen2-v1.0":
            from colpali_engine.models import ColQwen2, ColQwen2Processor
            from transformers.utils.import_utils import is_flash_attn_2_available
            
            # load model
            model = ColQwen2.from_pretrained(
                self.model_name,
                torch_dtype=torch.bfloat16,
                device_map="cuda:0", 
                attn_implementation="flash_attention_2" if is_flash_attn_2_available() else None,
                cache_dir=self.cache_dir,
            ).eval()

            colpali_processor = ColQwen2Processor.from_pretrained(
                self.model_name,
                cache_dir=self.cache_dir,
            )
    # ... functions for embedding images and queries
    # @modal.method()
    # async def batch_embed_images(self, images_base64: List[str]) -> np.ndarray:
    # @modal.method()
    # async def batch_embed_queries(self, queries: List[str]) -> np.ndarray:

生成向量嵌入:

$ modal run embedding.py::embed_dataset

步骤 3:在 VectorChord 中创建索引(使用 Vechord SDK)

向量嵌入生成并保存在 Modal Volume 中之后,我们首先需要将它们下载到本地:

$ modal volume get colpali-embedding-checkpoint /path/to/local/vidore_embeddings

现在,我们将使用 vechord SDK 把这些嵌入加载到 PostgreSQL 数据库中,并创建所需的多向量索引。vechord 提供了一种符合 Python 习惯、类似 ORM 的方式来与 Postgres 中的 VectorChord 进行交互。

%[https://github.com/tensorchord/vechord]

MultiVector = List[Vector[128]] # Assuming 128 dimensions for ColQwen2

lists = 2500 # lists is the number of the cluster
# Define the database table schema using vechord
class Image(Table, kw_only=True):
    uid: Optional[PrimaryKeyAutoIncrease] = None
    image_embedding: Annotated[MultiVector, MultiVectorIndex(lists=lists)] # Stores the document's visual embeddings 
    query_embedding: Annotated[MultiVector, MultiVectorIndex(lists=lists)]  # Stores the query's embeddings 
    query: str = None
    dataset: Optional[str] = None
    dataset_id: Optional[int] = None

# Connect to your PostgreSQL database
# Ensure the URL points to your local Docker or VectorChord Cloud instance
DB_URL = "postgresql://postgres:[email protected]:5432/postgres" # Default DB
vr = VechordRegistry("colpali", DB_URL)
vr.register([Image]) # Creates the table and multi-vector index if they don't exist

# Function to load embeddings from disk and yield Image objects
@vr.inject(output=Image)
def load_image_embeddings(path: str) -> Iterator[Image]:
    # ... (logic to load numpy arrays from disk, convert to List[Vector]) ...
    # ... (yield Image(...) instances) ...
    print(f"Loaded embeddings from {path}")

if __name__ == "__main__":
    # Path where you downloaded the embeddings from Modal
    embedding_dir = "/path/to/local/vidore_embeddings"
    load_image_embeddings(embedding_dir) # This triggers vechord to insert data
    print("Data loaded and indexed into VectorChord.")

运行索引脚本后,vechord 会自动处理建表、创建索引以及数据插入。

步骤 4:评估——效果究竟如何?

最后一步至关重要:评估检索性能。这套免 OCR 系统能否提供准确的结果?速度够快吗?我们将使用 vechord 在已建立索引的数据上运行查询,并重点考察 NDCG@10 和 Recall@10 指标。同时,我们还将测试 VectorChord 用于加速 MaxSim 计算的 WARP 优化的实际效果。在本教程中,我们将使用 vidore/arxivqa_test_subsampled 数据集进行评估查询,数据示例如下所示:

TOP_K = 10

# Define structure for results (optional but good practice)
class Evaluation(msgspec.Struct):
    map: float
    ndcg: float
    recall: float

TOP_K = 10
def evaluate(queries: list[Image], probes: int, max_maxsim_tuples: int) -> list[Evaluation]:
    result  = []
    for query in queries:
        vector = query.query_embedding
        docs: list[Image] = vr.search_by_multivec(
            Image, vector, topk=TOP_K, probe=probes, max_maxsim_tuples=max_maxsim_tuples
        )
        score = BaseEvaluator.evaluate_one(query.uid, [doc.uid for doc in docs])
        result.append(Evaluation(
            map=score.get("map"),
            ndcg=score.get("ndcg"),
            recall=score.get(f"recall_{TOP_K}"),
        ))
    return result

if __name__ == "__main__":
    # Select some queries from the benchmark dataset stored in the DB
    # Example: Get 100 queries from a specific subset
    test_queries: list[Image] = vr.select_by(
        Image.partial_init(dataset="vidore/arxivqa_test_subsampled"), limit=100
    )

    # ... optimize the param ...
    res: list[Evaluation] = evaluate(queries, probes=probes, maxsim_refine=maxsim_refine)
    print("ndcg@10", sum(r.ndcg for r in res) / len(res))
    print("recall@10", sum(r.recall for r in res) / len(res))
    print(f"Total execution time: {total_time:.4f} seconds")

运行评估脚本后,我们得到了令人印象深刻的结果:

# Disable WARP
ndcg@10 0.8615
recall@10 0.92
Total execution time: 810 seconds # Baseline accuracy and time

# Enable WARP
# There is no need to focus on specific times, 
# only relative times need to be taken into account, 
# as the tests were performed on a local model with poor performance.
ndcg@10 0.8353
recall@10 0.90
Total execution time: 41 seconds # Dramatic WARP Speed Boost

结果分析:

  • 高基线精度:首先看未开启 WARP 优化时的基线表现。系统取得了 0.8615 的出色 NDCG@10 和 0.92 的 Recall@10。这证实了 ColQwen2 嵌入配合 VectorChord 精确 MaxSim 搜索的根本有效性——即便不启用速度优化,也能在视觉文档检索上交出顶尖答卷。

  • WARP 带来的惊人加速:再来看启用 VectorChord WARP 优化后的效果。总执行时间从漫长的 810 秒骤降至仅需 41 秒!在整个评估查询集上实现了高达 约 18.7 倍的加速。这清晰地展示了 WARP 在加速 ColQwen2 等晚期交互模型所需的计算密集型 MaxSim 运算方面的强大威力。

  • 微乎其微的精度折衷:令人赞叹的是,如此大幅度的速度提升仅以极小的检索精度下降为代价:NDCG@10 略微降至 0.8353(差距不到 0.03),Recall@10 仅微降至 0.90(差距仅 0.02)。

总结:在 Postgres 中让视觉 RAG 变得更简单

在本教程中,我们将 ColQwen2 的视觉理解能力、PostgreSQL 内 VectorChord 的可扩展多向量搜索能力,以及 Modal 的高效 GPU 处理能力融为一体,成功构建了一套高性能的免 OCR RAG 系统。我们看到了这套技术栈如何让我们直接根据文档的视觉内容进行查询,从而绕开传统 OCR 流水线的重重陷阱。

直接在 Postgres 内部将这一视觉维度无缝集成到 RAG 系统中,为处理科学论文、发票、产品手册、历史档案等视觉信息丰富的文档应用打开了激动人心的可能性。

准备好亲自试试了吗?

这一方法标志着我们向更简洁、更稳健且具备视觉感知能力的文档检索系统迈出了重要一步。

参考资料

9 - 在 PostgreSQL 中用 30 亿向量守护地球

借助 VectorChord 在 PostgreSQL 中对 30 亿以上向量执行大规模向量搜索,获得快速、可扩展的相似度检索体验。

监测地球的健康状况是我们这个时代最严峻的挑战之一。我们的星球正面临巨大的环境压力,而要理解这些变化,就必须筛选超乎想象的海量卫星数据——持续不断捕获的 PB 级像素图像。从追踪亚马逊雨林的森林砍伐,到识别偏远地区的非法采矿作业,再到监测农业活动对敏感生态系统的影响,将这些原始数据转化为可落地的行动情报至关重要。

Earth Genome 打造的 Earth Index 平台正在正面迎击这一挑战。如今,它已实现全球覆盖,利用 AI 的力量让地球上的每一寸陆地表面都变得可搜索。这不仅是一项技术成就,更是一款旨在通过从卫星数据中提取易用、可操作的情报来赋能环境保护的全新工具。

%[https://www.earthgenome.org/]

在 VectorChord 团队,我们无比自豪地看到我们的 PostgreSQL 向量搜索扩展正在为这一宏伟而至关重要的使命提供核心动力。

Earth Index 在做什么?将像素转化为地球洞察

试想一下,要在覆盖数百万平方公里的卫星图像中,找出某种特定类型的小规模环境影响——比如一处未申报的采石场或一片新出现的森林砍伐区。传统上,分析这些数据需要深厚的专业知识和庞大的计算资源,这使得在广阔偏远的地区追踪特定威胁变得极其困难,对于预算有限的环保组织、调查记者和监管机构而言更是如此。下面的动画展示了 Earth Index 是如何高效地在亚马逊雨林中精准定位非法贩毒简易机场的——这无异于在足球场上找出一根牙签。

那么,Earth Index 是如何让在全球范围内寻找特定环境特征成为可能的呢?其中的奥秘在于一种前沿方法:使用通过对比学习(Contrastive Learning)训练的 AI 基础模型及其生成的向量嵌入(Vector Embeddings)。

以下是其创新工作流程的简要解析:

  1. 全球网格切片(Tiling the Globe):Earth Index 将地球的全部陆地表面划分为超过 32 亿个相互重叠的网格切片(Tiles),每个切片约占 10 公顷。

  2. 通过对比自监督学习“地球 DNA”:Earth Index 利用了 AI 领域的突破性进展,特别是使用自监督对比学习方法(如 DINO 目标函数)针对地球观测(Earth Observation, EO)训练的基础模型。该基础模型(例如 SSL4EO Vision Transformer)通过区分来自同一地理位置的图像对(正样本对)和来自不同地理位置的图像对(负样本对),学习卫星图像的基础视觉语言。这一过程在多样化的全球数据集(如 Harmonized Landsat Sentinel-2、Sentinel-1 雷达图像)上进行,无需人工显式标注“森林”或“城市”等特征。

  3. 生成向量嵌入(Generating Vector Embeddings):预训练完成后,该模型即作为编码器运行。将每个切片的图像输入其中,就会生成一个向量嵌入——这是一种丰富的数值表示或“数字指纹”,以高度压缩的格式(压缩率超过 10,000 倍)捕捉该切片的核心视觉与结构特征。这个高维向量就像是那片土地独一无二的“地球 DNA”。

  4. 打造可搜索的地球(Creating a Searchable Planet):这 32 亿个向量嵌入构成了 Earth Index 的核心。它们需要存储在一个专为快速查找数值相似向量而优化的系统中。用户只需提供一个想要查找的目标示例(例如在地图上点击一个已知的非法矿场或某种特定类型的农业基础设施),Earth Index 就会在这个海量数据集中进行搜索,找出全球(或特定区域内)具有相似向量指纹的其他位置。

真实世界的影响力:Earth Index 赋能环境保护实战

这一技术底座直接转化为了理解和保护环境的强大工具。它赋予了调查记者(如 Mongabay 和自由欧洲电台的记者)、原住民社区、环保及国际组织(如世界银行)以及研究人员前所未有的便捷与速度,去揭露隐蔽的环境变化、监测工业足迹,并为生态保护行动收集证据。下图展示了丰塞卡湾(Gulf of Fonseca)的养虾场分布。

以下是 Earth Index 在实际应用中的几个典型案例:

  • 打击森林砍伐与非法活动:在与 Mongabay 的合作中,Earth Index 穿透茂密的树冠层,成功识别出亚马逊雨林中隐蔽的非法贩毒简易机场。同样地,该平台还能快速定位非法采金留下的地表伤痕(例如正在破坏亚诺马米原住民领地的非法采矿点),帮助量化问题规模并指导执法行动。

  • 监测资源开采:**自由欧洲电台(Radio Free Europe)**利用 Earth Index 在巴尔干半岛识别出 1,300 多个采石场,揭露其中约有一半缺乏合法许可证,凸显了潜在的环境破坏和无序开采问题。同样的方法也可用于监测水力压裂井场、橡胶加工厂或其他工业设施。

  • 保护水质与生态系统:Earth Index 可以定位敏感水道附近的集中型动物饲养场(CAFO),以评估径流污染风险,或识别向关键红树林生态系统扩张的养虾场。

  • 追踪农业扩张:通过搜索与新开垦土地或特定作物类型相关的特征模式,该工具能够帮助监测自然栖息地向农业用地的转化过程。

基础设施挑战:以可承受的成本搜索数十亿向量

生成 32 亿个高维向量只是挑战之一;如何高效地托管并搜索这些向量则是另一道巨大的难关。Earth Index 需要一套能够满足以下要求的解决方案:

  • 应对超大规模:搜索数十亿向量需要专门的索引和查询能力。

  • 与地理空间数据无缝集成:Earth Index 高度依赖地理空间操作(例如在特定行政边界内过滤结果,或筛选靠近河流的区域)。这使得配备 PostGIS 的 PostgreSQL 成为其核心数据库自然且不可或缺的选择。而独立的专用向量数据库通常缺乏所需的丰富地理空间特性。

  • 保持高性能:用户需要相对快速地获得搜索结果,平台才能在调查和监测工作中发挥实效。

  • 控制成本:存储和搜索数十亿向量(尤其是当纯内存索引需要海量 RAM 时)对于许多组织——特别是非营利组织——而言,硬件成本往往高得令人望而却步。

因此,Earth Index 需要一种原生运行在其选定的 PostgreSQL 环境中、能够大规模扩展、性能卓越,同时不会在硬件成本上超出预算的向量搜索解决方案。

为什么 PostgreSQL 是地球级规模向量搜索的理想之选

尽管市面上存在专用的向量数据库,但 Earth Index 面临着一个核心诉求:将向量相似度搜索与丰富的地理空间数据及操作无缝集成。这正是 PostgreSQL 结合扩展生态真正大放异彩的地方:

  • 统一的数据平台:PostgreSQL(尤其是配合强大的 PostGIS 扩展)使 Earth Index 能够将向量嵌入与关键的地理空间元数据(切片位置、行政边界、与河流等地物的距离)保存在一起。这避免了为向量搜索和关系型/地理空间数据分别维护独立数据库所带来的复杂性以及潜在的数据同步问题。

  • 充分发挥 SQL 与 PostGIS 的威力:Earth Index 可以在单一查询接口中,将向量相似度搜索与标准 SQL 过滤条件以及复杂的 PostGIS 空间查询(例如,“在此特定国家公园边界内或靠近该水系的范围内,查找与此示例相似的向量”)无缝结合。独立的向量数据库通常缺乏这种成熟的地理空间查询能力。

  • 成熟度与生态系统:基于 PostgreSQL 构建意味着继承其数十年的研发积淀、享誉业界的稳定性、强大的工具链以及广泛的社区支持——这对于像 Earth Index 这样的关键任务平台至关重要。

然而,要高效地搜索 32 亿个向量,仅靠标准的 PostgreSQL 能力是远远不够的。

为什么 Earth Index 为了性能与性价比选择 VectorChord

虽然 PostgreSQL 生态系统提供了理想的底座,但在 32 亿向量的庞大规模下同时实现所需的性能与可承受的成本,依然是一项巨大的挑战。Earth Index 需要一种既能提供极速体验、又不会让预算爆表的解决方案。

要在性能、成本和集成度之间找到最佳平衡点并非易事。Earth Genome 首席技术官 Hutch Ingold 解释了他们的考量过程:

当我们为这 32 亿个向量评估解决方案时——充分考虑到日新月异的技术版图——标准 pgvector 在这一规模下无法满足我们的性能要求。像 Qdrant 这样的专用方案也面临困难:托管版本过于昂贵,而在我们的 Kubernetes 基础设施上自建集群部署在当时看来还不够成熟。我们也考察了云服务(例如 Google 的 Vertex AI Vector Search),但针对我们的数据集,每月高达 237,000 美元的目录报价实在令人望而却步。

这正是 VectorChord 成为关键赋能技术的原因:

  • 超越原版 PGVector 的卓越性能:VectorChord 带来了亟需的速度提升——查询速度最高提升 5 倍、插入吞吐量提升 16 倍、索引构建速度提升 16 倍——彻底解决了标准 pgvector 在如此庞大规模下遇到的性能瓶颈。

  • 基于磁盘索引实现高性价比扩展:至关重要的是,VectorChord 经过深度优化的磁盘索引(Disk-Based Indexing)(充分利用高性价比的 SSD)直接化解了 CTO 提到的高昂成本难题。它既避免了纯内存方案对海量 RAM 的苛刻需求,也避开了托管云向量服务天价的月度账单,使 32 亿向量的索引在财务上真正具备可行性。

  • 原生集成与可扩展性:作为一款 PostgreSQL 扩展,VectorChord 直接在他们选定的数据库内部提供了这些优势,无需改动现有基础设施架构。它专为高效扩展而构建,在轻松应对 Earth Index 全球监测平台所需的数十亿向量的同时,依然保持了流畅的交互式性能。

VectorChord 在 Earth Index 所熟悉且强大的 PostgreSQL 环境中,直接提供了高性能、大规模可扩展性与高性价比的完美结合。

Earth Index:技术架构与部署配置

为了高效且经济地管理其地球级数据集,Earth Index 在由三台强大的 AWS EC2 i8g.16xlarge 实例组成的集群上构建了后端系统,这些实例搭载了最新的 Graviton4 ARM 处理器和 Nitro SSD。每台实例提供 512 GB 内存、64 个 vCPU 和 15 TB 的 NVMe 存储,为计算和磁盘 I/O 奠定了坚实的基础。这套架构每月成本约为 12,000 美元,相比同类托管云向量服务预计每月 237,000 美元的费用节省了巨额开支,使整个项目在经济上得以持续运转。这些实例运行着由 VectorChord 和 PostGIS 增强的 PostgreSQL,高效地同时承载向量数据和其他应用组件。

核心数据驻留在跨三个节点的分区表结构中。主表 globe24 存储了每个切片的唯一 ID、其地理空间点几何信息(geometry(Point, 4326))、分区键(partkey),以及至关重要的、以 halfvec(384) 高效存储的 384 维 AI 生成嵌入向量。这种分片设计将 32 亿行数据分布在集群中,支持高效的并行处理。

该架构的一大核心优势在于能够执行复杂的综合查询。Earth Index 经常执行的 SQL 语句,能够将 VectorChord 的快速向量搜索(embedding <=> '{vector}'::vector)与 PostGIS 地理空间过滤(st_within(geom, '{geom}'))以及分区裁剪(partkey IN (...))无缝结合。这让用户能够在特定地理边界内高效地查找视觉相似的位置。即便面对数十亿向量,这种一体化方法依然展现出了实用的高性能:对于返回前 500 个结果的典型搜索,中位查询延迟(p50)约为 761 毫秒。

赋能大规模环保行动

通过在 PostgreSQL 内部提供高性价比、可扩展且紧密集成的向量搜索解决方案,VectorChord 助力 Earth Index 开展了这项极具突破性的工作。它使团队能够以可承受的成本管理庞大的“地球 DNA”数据库,确保这一强大的环境监测工具能够真正惠及最需要它的组织与社区。

立即加入 Earth Index 候补名单,探索你周边的环境挑战,了解你的生态足迹,并成为守护地球全球行动的一份子。

看到 Earth Index 利用 VectorChord 将卫星数据转化为可落地的环境情报,我们感到无比振奋。他们的成功充分证明了将前沿 AI、稳健的数据库技术与高效的向量搜索相结合,在应对全球重大挑战时所能迸发出的巨大能量。

有兴趣将大规模向量搜索引入你的 PostgreSQL 数据库吗?欢迎进一步了解 VectorChord 或立即开始体验!

%[https://github.com/tensorchord/VectorChord]

10 - PostgreSQL 向量搜索:内存与磁盘方案对比分析

PostgreSQL 中的向量搜索:全面对比 VectorChord、pgvector 与 pgvectorscale 在内存与磁盘场景下的性能表现

引言:真实场景中的挑战

并非所有场景都需要专用的向量数据库。借助 pgvector、pgvectorscale、VectorChord(演进自 pgvecto.rs)等 PostgreSQL 扩展,您可以在无需额外基础设施的情况下同时获得向量搜索与关系型数据库的能力。但实际落地并非总是顺风顺水,以下是真实用户的反馈:

  • 首次查询耗时 30 秒,而重复查询只需 100 毫秒。问题出在极其缓慢的 I/O 上。

  • 为 1,200 万行 75 维向量构建索引需要 3 个小时,这正常吗?

  • 在为 2 亿条 75 维向量构建索引 13 个小时后,我注意到进度到 30% 时内存占用高达 100GB/124GB,最终服务器直接崩溃了。

  • 我们遇到了一些挑战,特别是频繁的索引更新需求、吞吐量瓶颈、缺乏并行索引扫描以及没有内置量化支持。

在本文中,我们将深入探讨这些问题,帮助您不仅了解这些工具“是什么”,更理解其背后的“为什么”,从而做出明智的决策。让我们一起探索如何应对这些复杂挑战,并挑选最适合您需求的解决方案。

我们在向量数据库中最看重什么?

在 PostgreSQL 中,shared_buffers 是一个关键的内存区域,充当数据页(包括表数据和索引)的主缓存。通过将频繁访问的向量索引块存储在该内存缓冲区中,PostgreSQL 可以大幅减少缓慢的磁盘 I/O。当查询需要数据时,PostgreSQL 首先检查 shared_buffers。如果数据已在其中,即可直接快速读取而无需访问磁盘;如果不在,则从磁盘读取数据并加载到 shared_buffers 中以供后续查询使用。要配置此参数,可在 psql 中执行 ALTER SYSTEM SET shared_buffers = 'xGB' 并重启服务器。

假设您的表中已经存储了一些向量,索引构建完成后,就可以开始执行查询了。即便您已经为 PostgreSQL 配置了足够大的 shared_buffers,在执行首次查询(first query)时,系统仍然需要从磁盘读取索引。然而,对于重复查询(repeated query),由于向量可以直接从 shared_buffers 中获取,查询速度会快得多。

虽然冷启动或索引变更后的首次查询表现很重要,但系统的持续性能往往取决于索引中“热点”或频繁访问的部分被缓存到内存(shared_buffers)之后的查询执行速度——这正是重复查询所模拟的场景。因此,针对缓存命中的性能进行优化,对于许多真实生产应用而言至关重要。

在本文中,我们将对比各向量相似度搜索扩展在以下两种场景下的性能表现:

  • 内存充足时的查询性能

  • 内存受限且磁盘成为瓶颈时的查询性能

我们还将探讨与索引构建相关的其他维度,例如:

  • 索引构建速度(Index build speed):构建索引需要多长时间?如果实例拥有多个 CPU 核心,能否充分利用多核来加速构建?

  • 内存占用(Memory usage):创建索引需要预留多少内存?这是选择云实例规格时最重要的考量因素。

  • 磁盘占用(Disk usage):给定磁盘空间可以容纳多少向量?在选择配备不可扩展本地 NVMe SSD(一种高性能固态硬盘)的实例时,这也是一个重要考量。

  • 易用性(Ease of use):能否使用一条简单的 CREATE INDEX 命令创建索引?针对查询性能进行调优有多难?

最后,我们将讨论索引构建完成后的插入性能(insertion performance)。如果用户的业务包含流式数据并会触发频繁的索引更新,这一指标就显得尤为关键。

实验环境设置

下表展示了后续所有实验所使用的硬件配置与数据集信息:

内存中的向量 磁盘上的向量 索引构建 插入性能
实例类型 I4I Extra Large C6ID Large I4I Extra Large I4I Extra Large
vCPU 数量 4 2 4 4
存储 937 GB NVMe SSD 118 GB NVMe SSD 937 GB NVMe SSD 937 GB NVMe SSD
内存 32.0 GiB 4.0 GiB 32.0 GiB 32.0 GiB
PostgreSQL shared_buffers 24.0 GiB 2.0 GiB 1.0 GiB 24.0 GiB
插入行数 5,000,000 5,000,000 5,000,000 索引创建后插入 100k 行
距离度量 L2 距离 L2 距离 / /
测试查询数 10,000 10,000 / /

下表列出了我们在实验中使用的各个扩展的版本信息:

版本 镜像
VectorChord v0.2.2 tensorchord/vchord-postgres:pg17-v0.2.2
pgvector v0.8.0 tensorchord/vchord-postgres:pg17-v0.2.2 (已安装 pgvector v0.8.0)
pgvectorscale v0.6.0 timescale/timescaledb-ha:pg17.4-ts2.18.2-oss

内存中的向量:面向小规模数据

对于向量搜索系统而言,如果追求最佳性能,确保整个索引缓存在 shared_buffers 中至关重要,这通常更适合运行在内存优化型实例上。在这种情况下,shared_buffers 的大小理想情况下应当足以容纳整个索引。

在本次实验中,我们选择了一个中等规模的数据集 LAION-5m,其中包含 500 万条 768 维向量。为了将 LAION-5m 容纳在 shared_buffers 中,我们选用了配备 937GB NVMe SSD 和 32GB 内存的 AWS I4I Extra Large 实例。

在这台机器上,我们将 shared_buffers 设置为 24GB,先后测量首次查询和重复查询的性能。以下是 pgvector、pgvectorscale 和 VectorChord 在重复查询下的性能对比结果。

下图展示了查询速度(QPS)与搜索质量(Recall@10/100)之间的权衡关系。Recall@10 衡量在返回的前 10 个结果中找到真实最近邻的比例。通常来说,可以通过牺牲一定的召回率来换取更高的 QPS,反之亦然。更优秀的系统能够在相同 QPS 下实现更高的召回率,或者在相同召回率水平下提供更高的 QPS。我们的目标通常是在保持高召回率目标(例如 95%)的前提下最大化 QPS。

为什么 VectorChord 如此之快?

VectorChord 的 RabitQ 索引通过优化的向量压缩技术实现了极速查询,从而大幅加快了距离计算。这种压缩技术最大限度地减少了数据移动,实现了高效的硬件利用率和卓越的查询性能。

为了测量首次查询性能,我们在顺序执行 10,000 条测试查询之前清空了 shared_buffers。尽管缓冲区初始为空,但在同一次测试运行中,前序查询加载到缓存中的索引/数据页有时会被后续查询复用,这会导致瞬时 QPS 在运行过程中逐步上升。因此,我们报告全部 10,000 条查询的平均 QPS,以此作为冷缓存下性能的代表性指标。

总而言之,与其他扩展相比,VectorChord 在高召回率水平(例如 >95% Recall@10)下始终能实现更高的 QPS。至于另外两款扩展,pgvectorscale 在首次查询时表现更好,但一旦数据被缓存到内存中,pgvector 的表现则会反超。

磁盘中的向量:面向大规模数据

但在某些场景下情况会截然不同。面对数十亿规模的向量,单台机器的内存根本无法容纳整个索引。当待读取向量的体积超过内存大小时,shared_buffers 的加速效果会大幅减弱,绝大部分数据都必须从磁盘读取。

虽然此时 shared_buffers 的作用下降,但操作系统的页缓存(page cache)仍能提供一定的缓存加速,不过其性能通常低于 shared_buffers。

索引构建与数据迁移

VectorChord 的内部构建(internal build) 和 pgvectorscale 在构建索引时需要额外的内存,因此无法在仅有 4GB 内存的小型实例上直接构建索引。为了解决这一问题,我们先在另一台配备 24GB 内存的 I4I Extra Large 实例上完成索引构建,随后通过 AWS S3 对所有扩展的数据库数据目录进行备份和恢复。

为了模拟这种场景,我们将 LAION-5m 部署在一台配置小得多的 C6ID Large 机器上(仅配备 4GB 内存,并将 shared_buffers 设为 2GB)。这一配置确保内存处于不足状态,迫使绝大多数查询访问磁盘。

在这种情况下,由于内存不足,首次查询与重复查询之间的性能差异会小得多。因此,仅评估各扩展的重复查询性能即可反映真实水平。

一如既往,在高召回率水平(例如 >95% Recall@10)下,VectorChord 依然取得了远高于其他扩展的 QPS,而 pgvector 和 pgvectorscale 的 QPS 则较为接近。

索引构建体验

正如前文所述,用户同样十分关注索引构建阶段的各项指标:索引构建耗时、内存占用、磁盘占用以及易用性。我们在上述 LAION-5m 数据集实验的 CREATE INDEX 阶段测量了这些指标:

  • CPU 核心利用率(Core utilization):索引创建期间有效利用的可用 CPU 核心百分比。数值越高表明并行度越好。

  • 索引构建耗时(Index build time):SQL 命令构建索引所花费的时间。如果索引构建需要额外步骤(如 VectorChord 外部构建),该步骤耗时也计入其中。

  • 内存占用(Memory usage):索引构建期间的峰值内存使用量,包含分配给 PostgreSQL 的 1GB shared_buffers。

  • 磁盘占用(Disk usage):索引构建完成后磁盘上 PGDATA 的总大小(包含向量表数据和索引)。

VectorChord 中的外部构建与内部构建对比

VectorChord 支持两种不同的索引构建方式:内部构建(internal build)和外部构建(external build)。内部构建的使用体验与 pgvector 和 pgvectorscale 类似,而外部构建则需要額外执行一步 k-means 聚类。外部构建通常能带来更优异的查询性能,也是为大规模向量构建索引的推荐方式。如果您想深入了解 VectorChord 的外部构建,请阅读我们之前的博客文章。

前文所有查询性能均是在使用配备 A10 GPU 的独立机器完成外部构建后测得的。如果您坚持使用同一台 I4I Extra Large 实例进行外部构建,索引构建耗时将增加至 50 分钟,但这依然极具竞争力。

在本测试中,我们将 shared_buffers 设置为 1GB,并记录了各扩展的峰值内存占用。VectorChord 的内部构建与外部构建均将 K-means 聚类迭代次数设置为 25 次(25 iters)。在易用性方面,pgvector、pgvectorscale 和 VectorChord 的内部构建语法相似,只需一条 SQL 语句即可完成索引构建;而 VectorChord 的外部构建则相对复杂一些。

在大多数指标上,VectorChord 的外部构建均处于领先地位,但这在一定程度上牺牲了易用性,因为开发者需要在 PostgreSQL 外部实现额外的 K-means 聚类步骤并管理生成的质心。

如果您希望通过一条简单的 CREATE INDEX 命令直接上手,那么 VectorChord 的外部构建可能不是最省心的选择,您可以根据需求转向其他扩展或方式:

  • 选择 VectorChord 的内部构建 以获得更快的单命令索引构建速度

  • 如果您的内存确实非常捉襟见肘,可以选择 pgvector

  • 选择 pgvectorscale 以降低磁盘空间需求或获得更高的存储容量效率

插入性能:面向流式生成数据

面对流式生成的向量数据,插入速度会成为新的关注焦点。在实时数据分析系统中,每秒新写入的向量数量可能非常庞大。每当新向量添加到索引中时,索引自身也必须同步更新。这种能力通常被称为插入性能(insertion performance)。

为什么不同索引之间的插入性能存在差异?

对于基于图的索引(如 pgvector HNSW、pgvectorscale),每次索引更新通常都需要在图中执行多次搜索,并为新插入的向量建立新的边连接。而基于聚类的索引(如 VectorChord)只需找到对应的聚类分区并将新向量追加进去即可。

为此,我们设计了一项实验来检验各扩展的插入表现。在为 500 万条向量构建好索引之后,我们逐条向表中连续插入另外 100,000 条随机生成的向量,并统计全部插入完成所需的总时间。

可以看到,在插入数据时,VectorChord(1565 Insert/Sec)的表现远优于 pgvector(246 Insert/Sec)和 pgvectorscale(107 Insert/Sec)。对于大多数工作负载而言,每秒 100 次插入已经绰绰有余;但如果您发现生产环境中存在大量持续写入的向量(例如达到 500 Insert/Sec),这就可能成为系统瓶颈。

总结

基于上述实验结果,下表汇总了三款扩展在各个维度的差异。我们可以看到,VectorChord 的核心优势集中在性能上,而 pgvector 和 pgvectorscale 则分别在易用性和存储容量效率方面各有千秋。

如果您希望了解更详尽的对比,下表展示了三款扩展在各个具体维度的表现:

pgvector pgvectorscale VectorChord
易用性 简单 简单,与 pgvector 类似 简单(内部构建),较难(外部两阶段构建)
支持索引的最大维度 2000 16000 16000
调优难度 简单 中等,需针对不同查询手动调整 query_rescore 简单
可达到的最高召回率 较低梯队 中等梯队 第一梯队
重复查询 QPS 中等梯队 较低梯队 第一梯队
首次查询 QPS 中等梯队 第一梯队 第一梯队
磁盘索引 QPS 中等梯队 中等梯队 第一梯队
索引构建速度 中等梯队,支持多核并行 较低梯队,不支持多核并行 第一梯队,支持多核并行
索引构建峰值内存占用 2.1 GB 15.2 GB 1.1 GB(外部构建),9.8 GB(内部构建)
总磁盘占用 接近最大 最小 最大

想了解更多关于 VectorChord 与其他扩展的对比吗?在未来的博客中,我们将探索 PostgreSQL 向量扩展在更多场景下的差异,例如 BM25 混合搜索(将传统关键词搜索与向量相似度搜索相结合)以及带过滤条件的向量搜索(filtered vector search)。准备好在您的向量搜索场景中试用 VectorChord 了吗?欢迎下载扩展,加入我们的 Discord 社区,或通过 [email protected] 联系我们。

%[https://github.com/tensorchord/VectorChord]

11 - PostgreSQL 全文检索:用对方法其实很快(打破“慢”的迷思)

通过简单的标准优化将 PostgreSQL 全文检索速度提升 50 倍;结合 VectorChord-BM25 在 Postgres 中实现更快、更精准的 BM25 排序。

你可能在一些技术讨论或博客文章中看到过这样的观点:与专用搜索引擎或特定扩展相比,PostgreSQL 内置的全文检索(Full-Text Search,简称 FTS)在性能上表现不佳。近期一个典型的例子是 Neon 发布的博客文章《Performance Benchmark: pg_search on Neon》(链接)。

在这项基准测试中,Neon 将其数据库平台上搭载 pg_search 扩展(基于 Rust 的 Tantivy 库并通过 pgrx 构建)的查询性能,与使用 tsvector 和 GIN 索引的 Postgres 内置全文检索进行了对比。值得肯定的是,他们声明已通过在适当位置添加 GIN 索引对标准配置进行了优化(基准测试代码见此处)。

然而,虽然添加 GIN 索引是必要的第一步,但测试结果中“标准”配置表现出的显著慢速表明,PostgreSQL FTS 几个至关重要的额外优化步骤很可能被遗漏了。所谓“标准 FTS 天生比 pg_search 慢得多”的结论,很可能是建立在一个无意间被严重拖累的基线(baseline)之上的。

接下来,让我们深入探讨如何正确配置和使用标准 PostgreSQL FTS 以发挥其最佳性能,剖析 Neon 基准测试基线中很可能存在的具体配置缺陷,并展示内置 FTS 的真实速度。我们将通过翔实的数据证明:只需对基线配置应用这些标准优化,就能直接带来约 50 倍的性能提升。

基准测试环境回顾(来自 Neon 的分析)

该测试使用了一个包含 1000 万条日志记录的表,表结构类似如下:

CREATE TABLE benchmark_logs (
    id SERIAL PRIMARY KEY,
    message TEXT,
    country VARCHAR(255),
    severity INTEGER,
    timestamp TIMESTAMP,
    metadata JSONB
);

他们测试了多种查询类型,其中许多涉及检索 message 文本字段。在其“标准 Postgres”示例中,查询结构如下:

-- Create index on to_tsvector('english', message)
CREATE INDEX message_gin ON benchmark_logs USING gin (to_tsvector('english', message));
-- Example problematic query structure (likely used in Neon's baseline)
SELECT country, COUNT(*)
FROM benchmark_logs
WHERE to_tsvector('english', message) @@ to_tsquery('english', 'research')
GROUP BY country
ORDER BY country;

即便 message 上已经创建了函数 GIN 索引,这种查询写法以及很可能保留了默认值的 GIN 索引配置,正是导致标准 FTS 基线出现性能问题的根源。

我们的测试环境详情(复现基线场景)

为了展示正确优化对标准 FTS 的实际影响,我们使用了以下环境:

  • 实例规格:配备本地 NVMe SSD 的 AWS EC2 i7ie.xlarge(将磁盘 I/O 干扰降至最低)。

  • CPU:4 vCPU。

  • PostgreSQL 配置:通过 Docker 运行 PostgreSQL 16,并根据硬件资源进行了如下配置:

    docker run -d \
      --name my-postgres \
      --network=host \
      -v /mnt/localssd/pgdata:/var/lib/postgresql/data \
      -e POSTGRES_PASSWORD=mysecretpassword \
      postgres:latest \
      -c shared_buffers=8GB \
      -c maintenance_work_mem=8GB \
      -c max_parallel_workers=4 \
      -c max_worker_processes=4
  • 关于并行度的说明:上述配置默认提供 max_parallel_workers_per_gather = 2。Neon 的文章提到由于实例规格较大,其测试环境使用了 8 个并行工作进程,意味着其查询并行度(max_parallel_workers_per_gather 最高可达 8)高于我们的测试环境。换言之,我们对标准 FTS 的优化结果是在更低的查询并行度下取得的。

误区 #1:在查询时实时计算 tsvector(主要问题)

Neon 博客中展示的示例查询(在初级 FTS 教程中也很常见)在 WHERE 子句中实时计算 tsvector:

WHERE to_tsvector('english', message) @@ to_tsquery('english', 'research')

这会迫使 PostgreSQL:

  1. 执行高开销的重复计算:在查询执行期间,针对大量数据行反复运行 to_tsvector()(包括解析、词干提取等操作)。

  2. 限制索引效率:阻碍对 GIN 索引最直接、最高效的利用,即便基础 message 列上已经创建了表达式索引。

修复方法(构建合理的标准 FTS 基线):预计算并持久化存储 tsvector。

  1. 添加独立的 tsvector 列:

    ALTER TABLE benchmark_logs ADD COLUMN message_tsvector tsvector;
  2. 填充该列数据:

    UPDATE benchmark_logs SET message_tsvector = to_tsvector('english', message);
  3. 在该 tsvector 列上创建索引(并设置 fastupdate=off):(详见下方误区 #2 的修复)

  4. 改写查询语句:

    -- Optimized standard FTS query
    SELECT country, COUNT(*)
    FROM benchmark_logs
    WHERE message_tsvector @@ to_tsquery('english', 'research') -- Use the indexed column!
    GROUP BY country
    ORDER BY country;

误区 #2:忽略 GIN 索引的 fastupdate 参数(次要问题)

虽然 Neon 的基准测试正确地选择了 GIN 作为标准 FTS 的索引类型,但它很可能使用了默认设置:fastupdate=on。

  • fastupdate=on(默认):通过使用待处理列表(pending list)优先保障索引的更新速度。随着时间推移,这会显著拖慢搜索速度——尤其是在基准测试所用的大型静态数据集上,因为查询时必须同时扫描主索引和不断增长的待处理列表。此外,它还会导致索引膨胀和页面结构效率下降。

  • fastupdate=off:优先保障搜索速度。虽然索引写入更新会变慢,但生成的索引结构更加紧凑,且由于无需扫描待处理列表,查询速度会快得多。对于读密集型工作负载或测试检索性能的基准测试而言,这一设置必不可少。

为了围绕搜索速度进行公平对比,标准 FTS 基线应当使用 fastupdate=off。

修复方法(构建合理的标准 FTS 基线):

-- Create the GIN index correctly for search speed
CREATE INDEX idx_gin_logs_message_tsvector
ON benchmark_logs USING GIN (message_tsvector) -- Index the dedicated tsvector column
WITH (fastupdate = off);

性能影响:标准 FTS 获得约 50 倍加速

当我们将这些标准优化措施应用到标准 PostgreSQL FTS 配置上时,会发生什么?我们在 1000 万行数据集上测试了 Neon 示例中的同一条查询:

  • 未优化的标准 FTS(Neon 基线配置):~41301 ms(41.3 秒)

  • 优化后的标准 FTS(应用上述修复后):~877 ms(0.88 秒)

这证明仅需应用业界公认的最佳实践,就能让标准 PostgreSQL FTS 获得约 50 倍的速度提升。这彻底改变了基线性能表现,说明 Neon 基准测试中所用的参照点可能并未反映出内置 FTS 的真实潜力。而且,这一加速效果还是在可用查询并行度低于 Neon 测试环境的情况下实现的。

客观事实:相关性排序(ts_rank)的性能局限

不过,Neon 的分析在排序速度方面确实指出了一个切中要害的问题。与单纯找出匹配行相比,标准 PostgreSQL 中的 ts_rank 或 ts_rank_cd 等排序函数确实相对较慢。这是因为在执行 LIMIT 截断之前,排序函数需要获取并处理所有匹配行的数据,这会带来可观的 I/O 和 CPU 开销。

超越标准 FTS:使用 VectorChord-BM25 实现高性能排序

虽然标准 FTS 非常擅长快速过滤出匹配文档,但如果业务对精细、高性能的*相关性排序(ranking)*有严格要求,通常就需要借助专门的工具了。与其仅依赖内置排序函数,不如考虑使用 VectorChord-BM25。

VectorChord-BM25 是一款专为基于广受认可的 BM25 算法进行快速相关性排序而设计的 PostgreSQL 扩展。BM25 超越了简单的词项匹配,通过综合词项在文档内的词频(TF)以及在整个文档集合中的逆文档频率(IDF)来精准评估相关性。

Benchmark against ElasticSearch

VectorChord-BM25 的核心优势包括:

  • 极致性能:专为速度打造,在排序任务中表现优于 Elasticsearch(最高快 3 倍)及 pg_search 等同类方案。

  • 标准 BM25 算法:实现了经典、高效的相关性排序模型。

  • 专用索引结构:采用专用的 bm25 索引类型(结合了 Block-WeakAnd 等算法优化),这对于实现极速跳过非Top-K候选以及计算 BM25 评分所需的全局统计信息至关重要。

  • 专用数据类型:引入了 bm25vector 类型,专门用于存储适配 BM25 计算的分词表征。

如果你的应用高度依赖相关性排序的质量与速度,且标准 ts_rank 难以满足需求,VectorChord-BM25 在 PostgreSQL 内部提供了一个强大、原生集成的替代方案。

总结:标准 FTS 比你想象的要快得多

只要通过持久化的 tsvector 列和正确配置的 GIN 索引(针对读场景设置 fastupdate=off)进行合理优化,标准 PostgreSQL FTS 在查找匹配文档方面完全具备极其出色的性能。那些声称它很慢的基准测试,往往是在与未经妥善优化的基线进行对比。

而对于标准 ts_rank 力有未逮的高级相关性排序场景,像 VectorChord-BM25 这样的专用扩展通过专用数据类型和优化索引策略实现了 BM25 算法,能够带来数量级的性能飞跃。

请为你的任务选择合适的工具:首先正确优化标准 FTS;如果高性能相关性排序至关重要,不妨探索专为该目标打造的扩展方案。

%[https://github.com/tensorchord/VectorChord-bm25]

12 - VectorChord-BM25:推出独立的多语言高级搜索分词器 pg_tokenizer

我们很高兴宣布发布 VectorChord-BM25 0.2 版本,这款 PostgreSQL 扩展旨在将先进的基于 BM25 的全文检索排序能力直接带入数据库……

我们很高兴地宣布发布 VectorChord-BM25 0.2 版本!这款 PostgreSQL 扩展旨在将先进的基于 BM25 的全文检索排序能力直接引入您的数据库中。

借助 VectorChord-BM25,您无需依赖外部搜索引擎,即可直接利用信息检索领域的标准算法——BM25 的强大能力。本次发布迈出了重要一步,重点强化了核心文本处理组件:分词(tokenization),从而带来了更高的灵活性以及显著提升的多语言支持能力。

重磅消息:推出 pg_tokenizer.rs

VectorChord-BM25 0.2 的核心基石是一个完全重构并解耦的独立分词器扩展:pg_tokenizer.rs。

为什么要做出这一改变?我们意识到,分词(即将文本切分为有意义的最小单元 token 的过程)极其复杂,且高度依赖于具体的语言和业务场景。在单一庞大的扩展中同时支持多种语言的独特规则、自定义词典、词干提取(stemming)、停用词(stop words)以及不同的分词策略,正变得越来越笨重。

通过将分词器拆分到遵循宽松 Apache 许可证的独立项目(pg_tokenizer.rs)中,我们实现了以下几项核心优势:

  1. 模块化与灵活性:开发者现在可以独立于核心 BM25 排序逻辑来使用或定制分词器。

  2. 更易于社区贡献:pg_tokenizer.rs 职责更加聚焦,使社区能够更轻松地贡献新语言支持、分词技术或自定义过滤器。

  3. 更快的迭代速度:我们现在可以更快速地开发和发布分词器改进,而无需等待完整的 VectorChord-BM25 发布周期。

  4. 更强大的定制能力:无论处理何种语言,用户都能对排序前的文本处理流程获得更精细的控制权。

分词功能有哪些新特性(得益于 pg_tokenizer.rs)

这一全新架构在 v0.2 中解锁了多项强大特性:

  • 更广泛的语言支持:通过专用分词器(如针对中文的 Jieba 和针对日文的 Lindera),以及在海量多语言数据集上训练的强大多语言大模型分词器(如 Gemma2 和 LLMLingua2),直接满足多样化的语言处理需求。

  • 更丰富的分词特性:现在您可以对分词流水线进行更细粒度的控制:

    • 自定义停用词(Custom Stop Words):定义您自己的停用词列表,在索引和搜索期间忽略这些词。

    • 自定义词干提取器(Custom Stemmers):应用多种受支持语言的词干提取规则,甚至定义自定义规则。

    • 自定义同义词(Custom Synonyms):定义同义词列表,将不同的词视为等价词(例如 "postgres"、"postgresql"、"pgsql")。

    • 语言专属选项(Language-Specific Options):在需要时利用特定分词器(如 Lindera 或 Jieba)内部提供的细粒度控制选项。

代码示例!

下面来看看使用全新的分词器特性有多么简单。

1. 使用预训练的多语言 LLM 分词器(LLMLingua2)

基于大语言模型(LLM)的分词器非常适合处理包含多种不同语言的文本。

-- Enable the extensions (if not already done)
CREATE EXTENSION IF NOT EXISTS vchord_bm25;
CREATE EXTENSION IF NOT EXISTS pg_tokenizer;

-- Update search_path for the first time
ALTER SYSTEM SET search_path TO "$user", public, tokenizer_catalog, bm25_catalog;
SELECT pg_reload_conf();

-- Create a tokenizer configuration using the LLMLingua2 model
SELECT create_tokenizer('llm_tokenizer', $$
model = "llmlingua2"
$$);

-- Tokenize some English text
SELECT tokenize('PostgreSQL is a powerful, open source database.', 'llm_tokenizer');
-- Output: {2795,7134,158897,83,10,113138,4,9803,31344,63399,5} -- Example token IDs

-- Tokenize some Spanish text (LLMLingua2 handles multiple languages)
SELECT tokenize('PostgreSQL es una potente base de datos de código abierto.', 'llm_tokenizer');
-- Output: {2795,7134,158897,198,220,105889,3647,8,13084,8,55845,118754,5} -- Example token IDs

-- Integrate with a table
CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    passage TEXT,
    embedding bm25vector
);

INSERT INTO documents (passage) VALUES ('PostgreSQL is a powerful, open source database.');

UPDATE documents
SET embedding = tokenize(passage, 'llm_tokenizer')
WHERE id = 1; -- Or process the whole table

2. 创建带过滤器的自定义分词器(示例:英文)

本示例定义了一条自定义流水线,包括转小写、Unicode 规范化、跳过非字母数字词元、使用 NLTK 英文停用词表以及 Porter2 词干提取器。随后,它会自动训练模型并设置触发器,以便在插入或更新时自动对文本进行分词。

CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    content TEXT,
    embedding bm25vector
);

-- Define a custom text analysis pipeline
SELECT create_text_analyzer('english_analyzer', $$
pre_tokenizer = "unicode_segmentation"  # Basic word splitting
[[character_filters]]
to_lowercase = {}                       # Lowercase everything
[[character_filters]]
unicode_normalization = "nfkd"          # Normalize Unicode
[[token_filters]]
skip_non_alphanumeric = {}              # Remove punctuation-only tokens
[[token_filters]]
stopwords = "nltk_english"              # Use built-in English stopwords
[[token_filters]]
stemmer = "english_porter2"             # Apply Porter2 stemming
$$);

-- Create tokenizer, custom model based on 'articles.content', and trigger
SELECT create_custom_model_tokenizer_and_trigger(
    tokenizer_name => 'custom_english_tokenizer',
    model_name => 'article_model',
    text_analyzer_name => 'english_analyzer',
    table_name => 'articles',
    source_column => 'content',
    target_column => 'embedding'
);

-- Now, inserts automatically generate tokens
INSERT INTO articles (content) VALUES
('VectorChord-BM25 provides advanced ranking features for PostgreSQL users.');

SELECT embedding FROM articles WHERE id = 1;
-- Output: {1:1, 2:1, 3:1, 4:1, 5:1, 6:1, 7:1, 8:1}
-- Bm25vector based on the custom model and pipeline

3. 使用 Jieba 处理中文文本

CREATE TABLE chinese_docs (
    id SERIAL PRIMARY KEY,
    passage TEXT,
    embedding bm25vector
);

-- Define a text analyzer using the Jieba pre-tokenizer
SELECT create_text_analyzer('jieba_analyzer', $$
[pre_tokenizer.jieba]
# Optional Jieba configurations can go here
$$);

-- Create tokenizer, custom model, and trigger for Chinese text
SELECT create_custom_model_tokenizer_and_trigger(
    tokenizer_name => 'chinese_tokenizer',
    model_name => 'chinese_model',
    text_analyzer_name => 'jieba_analyzer',
    table_name => 'chinese_docs',
    source_column => 'passage',
    target_column => 'embedding'
);

-- Insert Chinese text
INSERT INTO chinese_docs (passage) VALUES
('红海早过了,船在印度洋面上开驶着。'); -- Example sentence

SELECT embedding FROM chinese_docs WHERE id = 1;
-- Output: {1:1, 2:1, 3:1, 4:1, 5:1, 6:1, 7:1, 8:1, 9:1, 10:1, 11:1, 12:1}
-- Bm25vector based on Jieba segmentation

(有关包括自定义停用词和同义词在内的完整示例,请参阅 pg_tokenizer.rs 文档。)

深入理解分词器配置

全新的分词器系统围绕两个核心概念构建:

  1. 文本分析器(Text Analyzer):定义如何将原始文本处理为词元序列。它由以下部分组成:

    • character_filters(字符过滤器):在切分之前修改文本(例如转小写、Unicode 规范化)。

    • pre_tokenizer(预分词器):将文本切分为初始词元(例如基于 Unicode 规则、Jieba、Lindera)。

    • token_filters(词元过滤器):在切分之后修改或过滤词元(例如移除停用词、词干提取、同义词替换)。

  2. 模型(Model):定义从处理后的词元到 BM25 最终使用的整数词元 ID 的映射关系。模型可以分为:

    • pre-trained(预训练模型):使用现成的词表和规则(如 bert-base-uncased、llmlingua2),非常适合通用场景及多语言使用。

    • custom(自定义模型):根据您自己的数据动态构建词表,专门针对您的语料库和语言量身定制。

您可以在 SQL 中以字符串形式传入简洁的 TOML 配置格式,将这些组件分开定义,也可以在创建分词器时直接内联定义。

立即上手 VectorChord-BM25 0.2!

本次发布大幅提升了 VectorChord-BM25 的灵活性与功能深度,特别是对于需要处理多语言或对文本处理有细粒度控制需求的用户。

我们诚邀您试用 0.2 版本并探索全新的分词能力。您的反馈至关重要——欢迎在我们的 GitHub 仓库中报告任何问题或提出功能建议。

立即使用 VectorChord-BM25 0.2 与 pg_tokenizer.rs,以强大的多语言灵活性升级您的 PostgreSQL 全文检索体验!


13 - 基于 Postgres 原生 BM25 与 VectorChord 的混合搜索

结合 BM25 与兼容 pgvector 的扩展 VectorChord,在 PostgreSQL 中无缝集成混合搜索。

在 RAG(检索增强生成)时代,如何从海量数据集中高效检索相关数据,对企业和开发者而言至关重要。传统的基于关键词的检索方法(例如使用 BM25 作为评分机制的方法)擅长根据词频和精确关键词匹配对文档进行排序,因此对于结构化查询和关键词密集型内容非常有效。然而,它们在理解同义词、上下文细微差别和语义意图方面往往力不从心。相反,基于向量的检索能够捕捉深层语义,在多样化查询上具备更好的泛化能力,但有时会在精确关键词匹配上牺牲准确性。混合搜索(Hybrid Search)通过将 BM25 的精确性与向量搜索的上下文理解能力相结合,弥合了这一差距,从而提供更快、更准确且具备语义感知的检索结果。

本文将探讨如何使用 VectorChord-bm25 进行关键词检索、使用 VectorChord 进行语义搜索,在 Postgres 中实现混合搜索。借助 VectorChord-bm25 基于 Block-WeakAnd 算法的 BM25 排序,以及 VectorChord 先进的向量相似度检索能力,你可以构建一个将关键词精确性与语义理解无缝融合的强大搜索系统。无论你是在构建推荐引擎、文档检索系统还是企业级搜索解决方案,本指南都将带你一步步释放混合搜索的全部潜力。

所有相关的基准测试代码均可在这里找到。

混合搜索解析

混合搜索将向量搜索和关键词搜索的结果加以合并。向量搜索基于查询与文档之间的语义相似度,而关键词搜索则依赖 BM25 排序算法。不过需要注意的是,BM25 本身只负责对文档进行打分,而广义的关键词搜索流程还包括分词(tokenization)、索引构建(indexing) 和查询解析(query parsing) 等步骤。混合搜索会使用重排序器(reranker)或对两种方法的结果进行融合(fusion)。常见的融合方法包括针对多路召回策略的 RRF(Reciprocal Rank Fusion,倒数排名融合)或加权评分;基于模型的重排序则包括交叉编码器(Cross-Encoder)模型(如 bge-reranker-v2-m3)以及多向量表征模型 ColBERT。

%[https://github.com/tensorchord/VectorChord]

什么是 VectorChord?

你可能已经熟悉 pgvector——一个开源的 Postgres 向量相似度搜索扩展。然而,面对大规模或性能敏感的场景,往往需要更先进的向量搜索解决方案,VectorChord 正是一个绝佳选择。

欢迎阅读博客文章《VectorChord:在 PostgreSQL 中以 $1 存储 40 万个向量》,深入了解它的设计初衷与架构。

%[https://github.com/tensorchord/VectorChord-bm25]

什么是 VectorChord-BM25

VectorChord-BM25 是一个用于关键词搜索的 PostgreSQL 扩展。它不仅实现了 BM25 排序算法,还内置了分词器和用于提升检索速度的 Block-WeakAnd 索引。

什么是 BM25?

BM25(Best Matching 25)是信息检索中常用的一种概率排序函数,用于评估文档与查询的匹配程度。它基于词频(TF)和逆文档频率(IDF)计算相关性得分,同时引入了文档长度归一化。该公式确保在文档内部频繁出现的词项(TF)以及在整个语料库中罕见的词项(IDF)获得合理的权重,从而提升搜索的准确性和相关性。IDF 衡量一个词在整个文档集合中出现的频率,出现次数越少,其权重越高;TF 则表示查询中的特定词项在给定文档中出现的频率,TF 越高,说明查询与该文档的相关性越强。

为什么需要 Postgres 原生的 BM25 排序实现?

在我们此前的博客文章《VectorChord-BM25:用 BM25 排序革新 PostgreSQL 搜索——比 Elasticsearch 快 3 倍》中,我的同事 Allen 已经给出了详尽而充分的阐述,欢迎参考。

在本教程中,我们将一步步指导你在 PostgreSQL 中使用 VectorChord-BM25 和 VectorChord 实现混合搜索。我们将涵盖以下内容:

  • 使用 VectorChord 进行语义搜索

  • 使用 VectorChord-BM25 进行关键词搜索

  • 重排序(Rerank)

前置条件

  1. 安装了 VectorChord-BM25 和 VectorChord 的 Postgres

如果你想快速复现本教程,可以使用 tensorchord/vchord-suite 镜像来运行 TensorChord 提供的多个扩展。

运行以下命令即可启动包含 VectorChord-BM25 和 VectorChord 的 Postgres 容器:

docker run   \           
  --name vchord-suite  \
  -e POSTGRES_PASSWORD=postgres  \
  -p 5432:5432 \
  -d tensorchord/vchord-suite:pg17-latest
CREATE EXTENSION IF NOT EXISTS vchord CASCADE;
CREATE EXTENSION IF NOT EXISTS pg_tokenizer CASCADE;
CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;
\dx
pg_tokenizer | 0.1.0   | tokenizer_catalog | pg_tokenizer
vchord       | 0.3.0   | public            | vchord: Vector database plugin for Postgres, written in Rust, specifically designed for LLM
vchord_bm25  | 0.2.0   | bm25_catalog      | vchord_bm25: A postgresql extension for bm25 ranking algorithm
vector       | 0.8.0   | public            | vector data type and ivfflat and hnsw access methods
  1. 准备嵌入模型与数据

在嵌入生成方面,我们可以使用预训练嵌入模型(如 BGE-M3)为文档生成向量嵌入。BGE-M3 是一款高质量的嵌入模型,以多功能、多语言和多粒度能力著称。

在评估验证方面,我们使用 BEIR 数据集——一个异构的信息检索基准测试集。它非常易于使用,支持在 15 个以上的多样化 IR 数据集上评估模型表现。

首先,你需要将数据加载到 PostgreSQL 中,并生成文档嵌入写入表中:

with self.conn.cursor() as cursor:
    cursor.execute(
        f"CREATE TABLE IF NOT EXISTS {self.dataset}_corpus (id TEXT, text TEXT, emb vector({self.vector_dim}), bm25 bm25vector);"
    )

    for did, doc in tqdm(zip(doc_ids, docs), desc="insert corpus"):
        emb = self.sentence_encoder.encode_doc(doc)
        cursor.execute(
            f"INSERT INTO {self.dataset}_corpus (id, text, emb) VALUES (%s, %s, %s)",
            (did, doc, emb),
        )

嵌入生成代码如下:

from FlagEmbedding import BGEM3FlagModel
class SentenceEmbedding:
    def __init__(self, model_name: str = "BAAI/bge-m3"):
        self.model = BGEM3FlagModel(
            model_name,
            use_fp16=True,
        )
    def encode_docs(self, documents: list[str]):
        return self.model.encode(
            documents,
            batch_size=32,
            max_length=8192,
        )['dense_vecs']

完整的数据处理和嵌入生成代码可在这里找到。

使用 VectorChord 进行语义搜索

对于语义搜索,我们采用在 VectorChord PostgreSQL 扩展中经过深度优化的 RabitQ 算法。RabitQ 是一种面向高维空间的量化算法,旨在提升嵌入向量等高维数据的存储与检索效率。

它通过将高维向量映射到低维离散空间,同时保留原始向量的相似度信息来实现这一目标。这一过程在大幅降低存储需求和计算开销的同时,依然保持了极高的检索精度。

centroids = min(4 * int(self.num**0.5), self.num // 40)
ivf_config = f"""
residual_quantization = true
[build.internal]
lists = [{centroids}]
build_threads = {workers}
spherical_centroids = false
"""
with self.conn.cursor() as cursor:
    cursor.execute(f"SET max_parallel_maintenance_workers TO {workers}")
    cursor.execute(f"SET max_parallel_workers TO {workers}")
    cursor.execute(
        f"CREATE INDEX {self.dataset}_rabitq ON {self.dataset}_corpus USING vchordrq (emb vector_l2_ops) WITH (options = $${ivf_config}$$)"
    )

如果你发现在自己的数据集上构建索引速度较慢,可以利用外部构建(external build)来加速这一过程。更多细节请参考我们此前的博客文章:《VectorChord 外部质心构建的优势与步骤》。

索引构建完成后,你可以使用以下 SQL 查询检索相似文档:

probe = int(0.1 * min(4 * int(self.num**0.5), self.num // 40))
with self.conn.cursor() as cursor:
    cursor.execute(f"SET vchordrq.probes = {probe}")
    cursor.execute(
        f"select q.id as qid, c.id, c.score from {self.dataset}_query q, lateral ("
        f"select id, {self.dataset}_corpus.emb <-> q.emb as score from "
        f"{self.dataset}_corpus order by score limit {topk}) c;"
    )

使用 VectorChord-BM25 进行关键词搜索

对于关键词搜索,我们需要使用**分词器(tokenizer)**将文本转换为 BM25 向量——它类似于存储词表 ID 和词频的稀疏向量。

cursor.execute(
    f"SELECT create_tokenizer('{self.dataset}_token', $$",
    f"tokenizer = 'unicode'",
    f"stopwords = 'nltk'",
    f"table = '{self.dataset}_corpus'",
    f"column = 'text'",
    f"$$);"
)
cursor.execute(
    f"UPDATE {self.dataset}_corpus SET bm25 = tokenize(text, '{self.dataset}_token')"
)

下面详细解释一下这一操作:tokenize 函数用于通过指定的分词器对文本进行分词。在本例中,我们使用了 Unicode 分词器。tokenize 函数的输出是一个 BM25 向量(即记录文本中每个词的词表 ID 及其出现频率的稀疏向量)。例如,1035:7 表示词表 ID 为 1035 的词在文本中出现了 7 次。

postgres=# select bm25 from fiqa_corpus limit 1;
-- Output: {1035:7, 1041:1, 1996:1, 1997:1, 1999:3, 2010:3, 2015:7, 2019:1, 2022:1, 2028:4, 2036:2, 2041:1, 2051:2, 2054...

创建好索引后,你就可以计算查询与文档向量之间的 BM25 得分了。请注意,这里的 BM25 得分是负数,意味着数值越小(绝对值越大),文档的相关性越高。我们之所以特意将其设计为负数,是为了让你能够直接使用默认的升序 ORDER BY 优先检索出最相关的文档。

with self.conn.cursor() as cursor:
    cursor.execute(
        f"SELECT q.id AS qid, c.id, c.score FROM {self.dataset}_query q, LATERAL ("
        f"SELECT id, {self.dataset}_corpus.bm25 <&> to_bm25query('{self.dataset}_text_bm25', q.text , '{self.dataset}_token') AS score "
        f"FROM {self.dataset}_corpus "
        f"ORDER BY score "
        f"LIMIT {topk}) c;"
    )

重排序与融合(Rerank/Fuse)

拿到语义搜索和关键词搜索各自的结果后,你就可以通过**融合(fuse)或重排序(rerank)**流程来合并结果。

  1. 融合(倒数排名融合,Reciprocal Rank Fusion (RRF))

RRF 的优势在于它不依赖于具体的得分量纲;它仅基于排名位置进行融合,因此非常适用于合并评分标准各不相同的多套排序系统。

RRF(d)=∑i=1n1k+ranki(d) \text{RRF}(d) = \sum_{i=1}^{n} \frac{1}{k + \text{rank}_i(d)}

其中:

  • d 是文档。

  • n 是排序系统的数量。

  • rank 是文档在对应排序系统中的排名。

  • k 是调节参数,通常取值为 60(经验值),用于控制排名对最终得分的影响程度。

for rank, (query_id, doc_id, _) in enumerate(result, start=1):
    if query_id not in rrf_scores:
        rrf_scores[query_id] = {}
    if doc_id not in rrf_scores[query_id]:
        rrf_scores[query_id][doc_id] = 0
    # Calculate and accumulate RRF scores
    rrf_scores[query_id][doc_id] += 1 / (k + rank)
  1. 交叉编码器(Cross-Encoder)模型重排序

在语义搜索阶段,我们已经使用双编码器(Bi-Encoder)分别将文档和查询向量化了。但这种独立编码的方式导致查询与文档之间缺乏深层交互。交叉编码器(Cross-Encoder)模型则会将查询和文档作为一个整体同时输入模型,使模型能够同时看到两者的内容,并通过 Transformer 层捕捉它们之间细粒度的语义关联。与 RRF 相比,Cross-Encoder 模型能够捕捉查询与文档之间细粒度的语义关系,因此精度更高,但计算速度相对较慢。

reranker = FlagReranker(
    'BAAI/bge-reranker-v2-m3', 
    query_max_length=256,
    passage_max_length=512,
    use_fp16=True,
    devices=["cuda:0"] # change ["cpu"] if you do not have gpu, but it will be very slow
) # Setting use_fp16 to True speeds up computation with a slight performance degradation

for query_id, docs in tqdm(results.items()):
    scores = reranker.compute_score(pairs, normalize=True) 
    for i, doc_id in enumerate(docs):
        bge_scores[query_id][doc_id] = scores[i]

评估结果

我们在多个 BEIR 数据集上测试了上述方法的 NDCG@10 指标,Top 10 结果如下:

数据集 语义搜索(Semantic Search) BM25 Cross-Encoder 重排序 RRF
FiQA-2018 0.40343 0.25301 0.42706 0.37632
Quora 0.88433 0.78687 0.89069 0.87014
SCIDOCS 0.16055 0.15584 0.17942 0.17299
SciFact 0.57631 0.68495 0.74635 0.67855

从上表可以得出以下结论:

  • 与单独使用 BM25 或语义搜索相比,Cross-Encoder 模型重排序能够在不同数据集上显著提升检索表现。

  • 在部分数据集上,RRF 可能会导致性能有所下降。因此,在决定是否使用 RRF 之前,请先在自身数据集上进行验证测试。如果验证有效,RRF 将是一个极其经济实惠的选择,因为它比使用 Cross-Encoder 模型重排序快得多,且几乎不消耗额外计算资源。

所有相关的基准测试代码均可在这里找到。如果你有任何问题,欢迎在 Discord 上与我们交流,或发送邮件至 [email protected]。

未来工作

实验结果凸显了混合搜索方法的巨大潜力。未来,我们将围绕以下几个方向进一步推进 RAG 能力:

  • 改进重排序方法:探索其他基于模型的重排序方法(例如 ColBERT 或 ColPali),以进一步提升重排序性能。

  • 集成图搜索(Graph Search):研究使用 Apache AGE 扩展进行图搜索,并将其与混合搜索方法相结合,从而进一步提升检索质量。

参考资料

14 - 使用 VectorChord 在 PostgreSQL 中实现 10,000 QPS 的向量搜索

作为 pgvecto.rs 的继任者,VectorChord 是一款专为向量搜索打造的 PostgreSQL 扩展,可轻松扩展至 10,000 QPS。

VectorChord 是一款专为可扩展、高性能且磁盘友好的向量相似度搜索而设计的 PostgreSQL 扩展,也是 pgvecto.rs 的继任者。在我们之前的博客文章中,我们展示了仅需每月 $250 的成本,VectorChord 就能在 1 亿规模的向量数据集上以 0.95 的精度实现 131 QPS——展现了大规模向量搜索中令人印象深刻的高性价比。

在此基础上,本文将聚焦于极致性能,探讨如何借助 VectorChord 在 PostgreSQL 中实现 10,000 QPS。我们将为您详细介绍 PostgreSQL 可靠的关系型底座如何与先进的向量索引相结合,以提供快速、可扩展的搜索能力;同时提供一套生产级部署方案与详尽的架构指南,帮助您在高负载下从容应对大规模向量计算。

背景

高每秒查询率(QPS)的向量搜索对于推荐引擎、图像识别和自然语言处理等实时应用至关重要。这类应用要求快速、高效的搜索以即时返回结果,这对于保持用户参与度和业务运营效率至关重要。通过利用 PostgreSQL 进行向量搜索,您不仅可以受益于其成熟稳健的关系型能力和先进的索引机制,还能避免维护独立专用向量数据库所带来的额外开销。这种统一的架构简化了系统集成、降低了整体复杂度,同时依然能够满足现代应用的高性能需求。

在实验中,我们在包含 500 万和 1 亿向量的 LAION 数据集上评估了性能,并将目标精度设定为 90% 以贴近真实业务场景。我们还探索了通过多副本扩展系统以提升 QPS 的方案,展示了 PostgreSQL 如何在保持高性能的同时有效承载严苛的向量搜索负载。

架构

在多节点集群上使用 VectorChord 比在单机上更具挑战性。与其他分布式服务类似,可靠的分布式向量搜索通常需要满足以下要求:

  • 易用性(Usability):声明式的配置管理

  • 弹性(Elasticity):根据 QPS 需求自动扩缩容节点

  • 负载均衡(Load Balancing):将请求高效分发至不同的只读副本实例

  • 高可用性(High Availability):维护数据库的多个副本,并在发生故障时自动从副本恢复

  • 持久性(Durability):安全、持久的存储,确保数据完整性并防止故障时发生数据丢失

为此,我们在 AWS EKS 上结合 OpenTofu 和 CloudNativePG 部署 VectorChord 服务,以管理提供上述能力的基础设施。以下是构建示例集群所用的核心模块:

  • OpenTofu:Terraform 的开源替代方案,用于在 AWS EKS 上实现基础设施即代码(IaC)

  • CloudNativePG:采用主从(primary/standby)架构管理高可用 PostgreSQL 数据库集群的完整生命周期

  • PGBouncer:轻量级 PostgreSQL 连接池,由 CloudNativePG 的 Pooler 模块提供

与 DiskANN 类似,VectorChord 可以充分利用磁盘资源,在内存不足时依然保持极具竞争力的性能。此外,VectorChord 还可以将索引缓存在内存中,以获得更高的查询性能。

提示

有关 VectorChord 技术细节的更多信息,请参阅我们之前的文章。

为了冲击 QPS 上限,我们假设内存能够完全容纳整个数据集,并以此为标准选择 AWS 机型。

下表列出了针对不同规模数据集能够将数据完全缓存在内存中的合适机型:

数据集 维度 / 规模 机型 / 内存
LAION-5m 768 / 5,000,000 r7i.xlarge / 32GB
LAION-100m 768 / 100,000,000 r7i.16xlarge / 512GB

测试结果

正如我们的文档所述,VectorChord 接收两个关键参数:nprob 和 epsilon。其中,nprob 定义了查询期间需要扫描的候选向量聚类数量,而 epsilon 则作为决定是否对结果进行重排序的阈值。这两个参数对 QPS 和 召回率(recall) 都有着关键影响,允许用户在搜索速度与准确度之间进行精细权衡。

  • 较低的 nprob 会带来更高的 QPS 和更低的召回率。

  • 较低的 epsilon 会带来更高但更不稳定的 QPS 以及更低的召回率。

如果固定这两个参数,每次实验的精度将保持一致,但实际 QPS 仍会随底层硬件的 CPU 和 I/O 性能而变化。为了确保精度保持在 0.9 以上,我们根据此前在较小数据集上进行的实验配置了参数:

数据集 nprob / epsilon 召回率 (recall)
LAION-5m 20 / 1.0 0.9324
LAION-100m 20 / 1.0 0.9042

我们在同一个可用区内部署了一台 r7i.4xlarge 实例作为客户端,向 VectorChord 集群发起查询。测试使用了 16 到 128 个并发工作线程,以模拟真实场景中数据库通常承受的并发水平。这一设置使我们能够评估 VectorChord 在不同负载和并发条件下的表现。

提示

r7i.4xlarge 拥有 16 个 vCPU,当并发请求达到 128 时,能够有效减少客户端侧的 CPU 争用。

遵循常见的 PgBouncer 最佳实践,我们采用了两类实例:一个主实例(primary)用于数据写入,多个副本实例(replica)用于处理只读工作负载。PgBouncer 充当负载均衡器,将查询高效地分发至各个后端实例。在基准测试期间,我们将查询端点指向 read-only (ro) 连接池,而 read-write (rw) 主实例在测试期间保持空闲。这种设置确保了读密集型工作负载下的最优资源利用率和可扩展性。

对于 500 万规模数据集,我们将实例数量从 5 台扩展到 15 台以达到 10,000 QPS。相比之下,对于 1 亿规模数据集,仅需 2 台实例即可达到该性能标准。下图展示了这些实验的结果,体现了 VectorChord 在不同数据集规模下的可扩展性与高效率。

成本

综上所述,如果您需要在上述数据集上实现 10,000 QPS,所需的最低硬件成本如下:

数据集 达到 10000+ QPS 的最低硬件配置 vCPU 数量 预估月度成本
LAION-5m r7i.xlarge × 7 4 × 7 = 28 vcpu $0.2646 × 7 × 720 = $1334
LAION-100m r7i.16xlarge × 2 64 × 2 = 128 vcpu $4.2336 × 2 × 720 = $6096
提示

主实例的成本未包含在上述计算中,因为它可以与其他 PostgreSQL 连接池共享和复用,从而进一步优化资源分配并降低总体开支。

通过这项实验,无论是在公有云还是私有数据中心,您都可以据此设计和扩展自己的集群,以达到理想的 VectorChord 搜索性能水平。即使在将性能置于首位的场景下,VectorChord 依然具备极具竞争力的价格,是满足高性能向量搜索需求的高性价比解决方案。

总结

无论您是在高性价比 VPS 上起步的初创团队,还是为追求极致性能而扩展大型集群的企业,VectorChord 都能为您打开无限可能。虽然本文聚焦于 10,000 QPS,但这远非 VectorChord 所能达到的上限。

凭借强大的索引算法,VectorChord 不仅通过高效利用内存和高速 SSD 超越了 pgvector 等标准 PostgreSQL 解决方案,更在处理海量数据和应对高 QPS 需求方面超越了专用向量数据库。在相同成本下,它不仅提供了更卓越的性能,还消除了维护独立向量搜索系统的复杂性。

如果您希望探索大规模向量搜索解决方案,欢迎加入我们的 Discord 社区或通过 [email protected] 联系我们。让我们一起释放数据的全部潜能!

%[https://github.com/tensorchord/VectorChord]

15 - VectorChord-BM25:用 BM25 排序彻底革新 PostgreSQL 搜索——速度比 ElasticSearch 快 3 倍

VectorChord-BM25 通过 BM25 排序增强 PostgreSQL 搜索能力,速度比 ElasticSearch 快 3 倍,支持 BERT、Tocken 和 Unicode 分词器

我们非常高兴与大家分享一款重磅新作:VectorChord-BM25,这是一款旨在让 PostgreSQL 全文检索更上一层楼的全新扩展。无论您是在构建小型应用还是管理大规模系统,这款工具都能将先进的 BM25 评分与排序能力直接引入 PostgreSQL,让您的搜索更智能、更迅速。

有哪些新特性?

  • BM25 评分与排序:借助 BM25 获得更精准、更相关的搜索结果,助您快速定位最重要的信息。

  • 优化的索引结构:得益于 Block WeakAnd 算法,即便面对大规模数据集,搜索也能更加快速高效。

  • 增强的分词能力:改进的词干提取与停用词处理机制,带来更高的准确度与更流畅的性能表现。

我们将 VectorChord-BM25 打造得简洁、强大且与 PostgreSQL 深度集成——因为我们坚信,优秀的工具应当让您的工作更轻松,而不是更复杂。欢迎您试用并向我们反馈您的想法。

%[https://github.com/tensorchord/VectorChord-BM25]

BM25

在深入介绍这项激动人心的成果之前,让我们先快速了解一下驱动现代搜索引擎的核心算法——BM25。BM25 是一种概率排序函数,用于衡量文档与搜索查询之间的相关性。

BM25 公式乍看之下可能有些复杂,但让我们逐步拆解,使其易于理解。公式如下:

score(Q,D)=∑q∈QIDF(q)⋅f(q,D) (k1+1)f(q,D)+k1⋅(1−b+b⋅∣D∣avgdl) \text{score}(Q, D) = \sum_{q \in Q} \text{IDF}(q) \cdot \frac{f(q, D) \, (k_1 + 1)}{f(q, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}

词频(Term Frequency, TF):项 f(q,D)f(q, D) 表示查询词 q 在文档 D 中出现的频率。一个词在文档中出现的次数越多,其相关性得分就越高。例如,如果“AI”在文档 A 中出现 5 次,在文档 B 中出现 2 次,那么文档 A 在该词上的得分更高。

逆文档频率(Inverse Document Frequency, IDF):项 IDF(q)\text{IDF}(q) 衡量查询词 q 在所有文档中的罕见或常见程度。罕见词(例如“NVIDIA”)会比常见词(例如“revenue”)获得更高的权重。这确保了具有区分度的独特词汇对相关性得分产生更大的影响。

文档长度归一化(Document Length Normalization):项 ∣D∣|D| 表示文档 D 的长度,而 avgdl\text{avgdl} 是整个文档集合中所有文档的平均长度。公式的这一部分会根据文档长度调整得分,确保较短的文档不会受到不公平的惩罚。例如,一篇简明扼要的报告不会被另一篇仅仅顺带提及查询词的长篇大论所掩盖。

调优参数(Tuning Parameters):参数 k1 和 b 允许对公式进行微调。k1 控制词频对得分的影响程度,而 b 则平衡文档长度归一化的作用强度。可以根据特定数据集调整这些参数以优化检索效果。

设想在一个财务报告数据库中搜索“Tesla stock performance(特斯拉股票表现)”。一份提及“Tesla”十次的报告得分会高于仅提及两次的报告。然而,“stock(股票)”一词可能出现在许多报告中,因此它所获得的权重会低于更具指向性的“Tesla”。此外,一份专门探讨特斯拉股票表现的简短报告,也不会被一份只是略微提及特斯拉的冗长报告所淹没。

Postgres 中的现有方案

了解了基础知识后,让我们来看看 PostgreSQL 中现有的全文检索解决方案。

PostgreSQL 通过结合 tsvector 数据类型和 GIN(通用倒排索引,Generalized Inverted Index)索引,提供了内置的全文检索支持。其工作原理如下:首先将文本转换为 tsvector,将内容分词为词位(lexemes,即标准化的词形,有助于提高搜索效率);随后在 tsvector 列上创建 GIN 索引,从而显著加快查询速度,即使面对大文本字段也能快速检索出相关文档。这种文本处理与高级索引的结合,使 PostgreSQL 成为需要高效全文检索的应用的强大且可扩展的解决方案。

-- Create a table with a text column
CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    content TEXT,
    content_vector tsvector
);

-- Insert sample data with tsvector conversion
INSERT INTO documents (content, content_vector) VALUES
('PostgreSQL is a powerful, open-source database system.', to_tsvector(...)),
('Full-text search in PostgreSQL is efficient and scalable.', to_tsvector(...)),
('BM25 is a ranking function used by search engines.', to_tsvector(...));

-- Create a GIN index on the tsvector column
CREATE INDEX idx_content_vector ON documents USING GIN (content_vector);

-- Query using tsvector, rank results with ts_rank, and leverage the GIN index
SELECT id, content, ts_rank(content_vector, to_tsquery('english', 'PostgreSQL & search')) AS rank
FROM documents
WHERE content_vector @@ to_tsquery('english', 'PostgreSQL & search')
ORDER BY rank DESC;

然而,PostgreSQL 原生方案存在一个局限:它缺乏像 BM25 这样的现代相关性评分机制。相反,它会先返回所有匹配的文档,然后再依赖 ts_rank 对它们进行重排序,这在数据量大时往往效率低下。这使得用户很难快速找出最重要的结果,尤其是在处理大规模数据集时。

另一种解决方案是 ParadeDB,它将全文检索查询下推给 Tantivy 以获取结果。它支持 BM25 评分以及排除词(negative terms)等复杂查询模式,旨在成为 ElasticSearch 的完全替代品。然而,它使用自己独特的语法进行过滤和查询,并将过滤操作委托给 Tantivy 而不是直接依赖 Postgres。其实现需要深入挂载 Postgres 的查询规划与存储钩子(hooks),这可能会引发潜在的兼容性问题。

相比之下,VectorChord-BM25 采取了截然不同的路径。它专注于以轻量级、原生的方式将 BM25 排序引入 PostgreSQL。我们从零实现了 BM25 排序算法和 Block WeakAnd 技术,将其构建为自定义操作符和索引(类似于 pgvector)来加速查询。VectorChord-BM25 的设计直观而高效,在完全融入 PostgreSQL 生态系统的同时,提供了一套无缝的 API 来增强全文检索与排序能力。

VectorChord-BM25

我们的实现引入了一种全新的方法:从零开发 BM25 索引和搜索算法,同时与 PostgreSQL 现有的扩展开发接口无缝集成,以确保最大程度的兼容性。

受 PISA 引擎、Tantivy 和 Lucene 的启发,我们引入了 BlockMax WeakAnd 算法,以支持高效的基于得分的剪枝过滤与排序。此外,我们还采用位打包(bitpacking)技术进行 ID 压缩以提升整体效率,并重新实现了基于用户数据的分词器,使其性能与行为更紧密地对齐 ElasticSearch。

下表对比了我们的实现与 ElasticSearch 在来自 bm25-benchmarks 的多个数据集上的 Top1000 每秒查询数(QPS)(即系统在每次查询检索前 1000 条结果时每秒能处理的查询数量):

在测试的数据集上,我们的实现平均达到了 ElasticSearch 3 倍的 QPS,充分展示了其高效率与可扩展性。然而,光有速度还不够——我们同样高度重视准确度。为了确保检索相关性,我们评估了衡量排序质量的核心指标——NDCG@10(Normalized Discounted Cumulative Gain at 10,前 10 位归一化折损累计增益)。

提示

NDCG@10 是评估排序列表前 10 个位置中文档相关性的指标。它不仅考量每篇文档自身的相关度,还兼顾其所处的排序位置,确保排名越靠前的相关文档对总得分的贡献越大。

下表对比了 VectorChord-BM25 与 ElasticSearch 在各个数据集上的 NDCG@10 得分:

我们投入了大量精力使 VectorChord-BM25 的行为与 ElasticSearch 保持对齐,以确保对比的公平性与准确性。如表所示,我们的实现在大多数数据集上都取得了与 ElasticSearch 相当的 NDCG@10 得分,在部分数据集(如 trec-covid 和 scifact)上甚至超越了 ElasticSearch。

我们将在后文分享对齐工作的技术细节,包括我们如何处理分词、评分及其他关键组件以达成这些结果。在此之前,让我们先看看如何在 PostgreSQL 中使用 VectorChord-BM25。

快速上手

为了帮助您快速上手 VectorChord-BM25,我们在 GitHub README 中准备了一份详尽的指南,引导您完成安装与配置过程。下面提供了一个完整的示例,展示如何在 Postgres 中使用 VectorChord-BM25 进行 BM25 全文检索。每段 SQL 代码都附有清晰的说明,解释其作用及用途。

该扩展主要由三个核心组件构成:

  • 分词器(Tokenizer):将文本转换为 bm25vector(类似于存储词表 ID 及其词频的稀疏向量)。

  • bm25vector:以适合计算 BM25 得分的格式表示分词后的文本。

  • bm25vector 索引:加速搜索与排序过程,使其更加高效。

如果您想对一段文本进行分词,可以使用 tokenize 函数。它接收两个参数:待分词的文本以及分词器的名称。

-- tokenize text with bert tokenizer
SELECT tokenize('A quick brown fox jumps over the lazy dog.', 'Bert');
-- Output: {2474:1, 2829:1, 3899:1, 4248:1, 4419:1, 5376:1, 5831:1}
-- The output is a bm25vector, 2474:1 means the word with id 2474 appears once in the text.

BM25 评分的一个独特之处在于它依赖全局文档频率(global document frequency)。这意味着一个词在某篇文档中的得分,会受到该词在整个文档集合中出现频率的影响。因此,要计算 bm25vector 与查询之间的 BM25 得分,首先需要构建一个文档集合。准备就绪后,即可使用 <&> 操作符执行计算。

下面是一个分步示例。首先,创建一张表并插入一些文档:

-- Setup the document table
CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    passage TEXT,
    embedding bm25vector
);

INSERT INTO documents (passage) VALUES
('PostgreSQL is a powerful, open-source object-relational database system. It has over 15 years of active development.'),
('Full-text search is a technique for searching in plain-text documents or textual database fields. PostgreSQL supports this with tsvector.'),
...
('Effective search ranking algorithms, such as BM25, improve search results by understanding relevance.');

然后对其进行分词:

UPDATE documents SET embedding = tokenize(passage, 'Bert');

在 bm25vector 列上创建索引,以便收集全局文档频率统计信息:

CREATE INDEX documents_embedding_bm25 ON documents USING bm25 (embedding bm25_ops);

现在我们可以计算查询与向量之间的 BM25 得分了。值得注意的是,返回的 BM25 得分是负数——这是特意设计的。得分越高(即负得越少)代表文档越相关。我们之所以故意将得分设为负数,是为了让您可以直接使用默认的 ORDER BY(升序)子句,轻松将最相关的文档排在最前面。

SELECT id, passage, embedding <&> to_bm25query('documents_embedding_bm25', 'PostgreSQL', 'Bert') AS rank
FROM documents
ORDER BY rank
LIMIT 10;

其他分词器

除了 BERT 之外,VectorChord-BM25 还支持 Tocken 和 Unicode 分词器。Tocken 是一个在 wiki-103-raw 数据集上以最小词频(min_freq)为 10 预训练的 Unicode 分词器。由于它是预训练分词器,其使用方式与 BERT 类似:

SELECT tokenize('A quick brown fox jumps over the lazy dog.', 'Tocken');

Unicode 则是一个根据您的业务数据构建词表的分词器,其行为类似于 Elasticsearch 和其他全文检索引擎中的标准分词器。要启用它,您需要使用 create_unicode_tokenizer_and_trigger(vocab_list_name, table_name, source_text_column, tokenized_vec_column) 为您的数据专门创建一个分词器:

CREATE TABLE documents (id SERIAL, text TEXT, embedding bm25vector);
SELECT create_unicode_tokenizer_and_trigger('test_token', 'documents', 'text', 'embedding');

INSERT INTO documents (text) VALUES ('PostgreSQL is a powerful, open-source object-relational database system.');
CREATE INDEX documents_embedding_bm25 ON documents USING bm25 (embedding bm25_ops);
SELECT id, text, embedding <&> to_bm25query('documents_embedding_bm25', 'PostgreSQL', 'test_token') AS rank
    FROM documents
    ORDER BY rank
    LIMIT 10;

如果您想了解更多相关信息,请查阅分词器文档。

比 ElasticSearch 更快(真的吗?)

math Memes & GIFs - Imgflip

为了确保我们的新插件不仅速度快,更能提供有意义、高相关性的检索结果,我们使用 bm25-benchmarks 对 VectorChord-BM25 进行了严格的评估。我们采用了信息检索领域广泛认可的 BEIR 基准测试,重点关注两个核心指标:衡量速度的 QPS(每秒查询数) 与评估相关性及排序准确度的 NDCG@10。

在最初的版本中,我们欣喜地看到系统的 QPS 比 ElasticSearch 高出 3 到 5 倍。然而,我们也注意到在某些数据集上,我们的 NDCG@10 得分明显低于 ElasticSearch。经过深入分析,我们发现造成这些差距的原因并非索引实现本身,而是分词处理方式上的差异。

为了解决这一问题,我们投入了大量精力将分词流程与 ElasticSearch 对齐,以确保对比更加准确、公平。

问题 1:停用词表

提示

停用词(stopword) 是指在文本处理过程中被过滤掉的常见词,因为它们几乎不携带实质语义——例如英语中的“the”、“is”和“at”。

ElasticSearch 默认使用 Lucene 的 Standard Tokenizer,它依赖一个相对较短的停用词表。相比之下,我们的初始实现使用了 NLTK 中包含词汇多得多的停用词表。因此,ElasticSearch 的搜索会包含某些停用词,导致倒排列表更长、需要花费更多扫描时间——进而影响了查询性能。

问题 2:词干提取

提示

词干提取器(stemmer) 会将词语还原为其词根形式,例如“running”和“ran”都会被还原为“run”。

起初我们使用 rust-stemmer 库中的 Snowball 词干提取器来处理词形变化,但观察到了与 ElasticSearch 实现不一致的地方。经过排查,我们发现 rust-stemmer 中打包的 Snowball 版本已经过时。遵循官方 Snowball 仓库的指南,我们使用最新版本重新生成了词干提取代码文件。

当我们将系统的停用词表和词干提取器都与 ElasticSearch 对齐后,我们的性能优势从 300% 缩小到了 40%。即便如此,在 nq、fever 和 climate-fever 这三个数据集上,仍然存在明显的质量差距。更深入的对比揭示了一个微妙但关键的细节:在 bm25-benchmark 中,ElasticSearch 对数据的预处理方式与其他框架不同,这正是造成剩余差异的原因。

问题 3:数据预处理

在 BEIR 数据集中,每篇文档都包含 title(标题)和 text(正文)两个字段。虽然大多数框架会将这两个字段拼接成一个字符串进行索引,但 ElasticSearch 接收 JSON 输入,并对 title 和 text 分别建立索引。在查询期间,ElasticSearch 执行 multi_match 操作,独立搜索这两个字段并合并它们的得分(采用较高得分加上 0.5 倍较低得分的策略)。这种方法能显著提升 NDCG@10 结果,但需要搜索两个独立的索引,从而对性能产生较大影响。

为了确保公平对比,我们将 title 和 text 字段拼接后重新运行了 ElasticSearch 测试。经过这一调整,VectorChord-BM25 的检索质量与 ElasticSearch 完全持平。有趣的是,在使用拼接字段后,ElasticSearch 的 QPS(几何平均值) 从 135 提升到了 341,在 Top10 查询测试中比 VectorChord-BM25 的 271.91 QPS 快了 25%。然而,在 Top1000 测试中,VectorChord-BM25 达到了 112 QPS,而 ElasticSearch 为 49 QPS——我们的实现快了 2.26 倍。

这一经历凸显了在不同 BM25 全文检索实现之间进行公平性能对比的难度。分词器是一个极其复杂且影响深远的组件,而 ElasticSearch 错综复杂的默认设置更是为评估过程增加了另一层复杂性。

未来规划

目前该项目仍处于早期阶段,我们已经明确了多个可进一步优化性能的方向。分词本身是一个天然复杂的过程——即便是处理英文,我们也面临着诸多权衡与决策。我们的下一步计划是将分词过程彻底解耦,将其转化为一个独立且可扩展的扩展。这将使我们能够支持更多语言,允许用户自定义分词流程以获得更好的效果,甚至引入同义词处理等高级特性。

我们的最终目标是让用户能够在 PostgreSQL 上轻松执行高质量、基于相关性的全文检索。结合 VectorChord,我们致力于为 RAG(检索增强生成)提供一流的数据基础设施。毕竟,选择 PostgreSQL 永远是一个稳健的决定!

如果您对 PostgreSQL 中的向量搜索或全文检索有任何疑问,欢迎随时联系我们:

%[https://github.com/tensorchord/VectorChord-bm25]

更多基准测试

除了 Top1000 基准测试外,我们还针对 Top10 结果进行了广泛评估。这些基准测试进一步展示了我们的实现在不同数据集上的性能表现。如果您有兴趣深入了解这些结果,欢迎参考下方提供的数据图表。

16 - 我们真的需要专门的向量数据库吗?(2023)

注:本文最初发表于 2023 年,探讨了专用向量数据库的适用性与必要性,特别是与 Postgres 及其向量能力之间的对比。

注:本文最初发表于 2023 年,探讨了专用向量数据库的适用性与必要性,特别是它与 Postgres 及其能力之间的关系。如需了解关于此话题的更多见解与讨论,请参阅以下链接:Do we really need a specialized vector database? | Hacker News。

随着大语言模型(LLM)的普及,向量数据库也成为了热门话题。只需寥寥几行简单的 Python 代码,向量数据库就能充当 LLM 廉价却高效的“外挂大脑”。但我们真的需要一个专门的向量数据库吗?

首先,让我简要介绍一下为什么 LLM 需要使用向量搜索技术。向量搜索是一个存在已久的问题:给定一个对象,在集合中找出与之最相似对象的过程就是向量搜索。文本、图像等都可以转换为向量表示,从而将文本或图像的相似性问题转化为向量相似性问题。

在上面的例子中,我们将不同的单词转换为三维向量,因此可以在三维空间中直观地展示不同单词之间的相似度。例如,“student(学生)”与“school(学校)”之间的相似度要高于“student(学生)”与“food(食物)”之间的相似度。

回到 LLM,上下文窗口长度的限制是一个主要挑战。例如,ChatGPT 3.5 的上下文长度限制为 4k 词元(tokens)。这给 LLM 的上下文学习能力带来了显著阻碍,并对用户体验产生负面影响。然而,向量搜索为这个问题提供了一个优雅的解决方案:

  • 将超出上下文长度限制的长文本切分为较短的片段(chunks),并将不同的片段转换为向量(embeddings)。

  • 在将提示词(prompt)输入给 LLM 之前,先将提示词转换为向量(embedding)。

  • 使用提示词向量进行搜索,找出最相似的文本片段向量。

  • 将最相似的文本片段与提示词拼接在一起,作为给 LLM 的最终输入。

这就像给 LLM 配备了一个外部记忆库,允许它从记忆库中搜索最相关的信息。这种记忆能力正是向量搜索所赋予的。如果你想了解更多细节,可以阅读这两篇文章(文章 1 和 文章 2),它们解释得更加透彻。

在 LLM 应用中,向量数据库已成为不可或缺的一部分,其中最重要的原因之一就是它的易用性。配合 OpenAI Embedding 模型(如 text-embedding-ada-002)使用时,只需大约十行代码,就能将用户的查询提示词转换为向量并完成整个向量搜索流程。

def query(query, collection_name, top_k=20):

    # Creates embedding vector from user query
    embedded_query = openai.Embedding.create(
        input=query,
        model=EMBEDDING_MODEL,
    )["data"][0]['embedding']
    
    near_vector = {"vector": embedded_query}

    # Queries input schema with vectorized user query
    query_result = (
        client.query
        .get(collection_name)
        .with_near_vector(near_vector)
        .with_limit(top_k)
        .do()
    )
    
    return query_result

在 LLM 中,向量搜索主要扮演“召回(Recall)”的角色。简单来说,召回就是在候选集中找出最相似的对象。在 LLM 场景下,候选集是所有的文本片段,而最相似的对象就是与提示词最相关的文本片段。在 LLM 的推理过程中,向量搜索被视为实现召回的主要手段。它易于实现,并且可以利用 OpenAI Embedding 模型解决最繁琐的“文本转向量”问题;剩下的就是一个独立、纯粹的向量搜索问题,现有的向量数据库可以很好地完成,因此整个流程显得格外顺畅。

顾名思义,向量数据库是专为“向量”这种特殊数据类型设计的数据库。向量的相似度计算原本是一个复杂度为 O(n^2) 的问题,因为它需要将集合中的所有向量两两比较。为此,业界提出了近似最近邻(ANN)算法。通过使用 ANN 算法,向量数据库采用“空间换时间”的思想预先计算并构建向量索引,从而大幅加速相似度计算过程。这与传统数据库中的索引非常相似。

因此,向量数据库不仅性能强大,而且极易上手,看起来与 LLM 简直是天作之合!(真的如此吗?)

也许通用数据库会是更好的选择?

我们已经聊过了向量数据库的优势与好处,但它的局限性又是什么呢?SingleStore 的一篇博客文章很好地回答了这个问题:

向量和向量搜索只是一种数据类型和查询处理方法,而不是一种全新的数据处理范式的根基。使用专用向量数据库(SVDB)会导致我们在使用多套专用系统的客户身上一次又一次看到(并解决)的老问题:数据冗余、过度的数据迁移、分布式组件之间的数据值不一致、专门技能带来的额外人力成本、额外的软件许可费用、有限的查询语言能力与可编程/可扩展性、有限的工具集成,以及相比真正的 DBMS 而言堪忧的数据完整性与高可用性。

我认为其中有两个问题尤为重要。第一个是数据一致性问题。在原型验证阶段,向量数据库非常合适,易用性高于一切。然而,向量数据库是一个独立的系统,与 OLTP 数据库、OLAP 数据湖等其他数据存储系统完全解耦。因此,数据必须在多个系统之间进行同步、流转和处理。

试想一下,如果你的业务数据已经存储在 PostgreSQL 这样的 OLTP 数据库中。为了使用独立的向量数据库执行向量搜索,你需要先从数据库中提取数据,然后调用 OpenAI Embedding 等服务将每条数据转换为向量,最后再将其同步到专用的向量数据库中。这增加了大量的系统复杂度。更严重的是,如果用户在 PostgreSQL 中删除了一条数据,但向量数据库中的对应向量未被删除,就会引发数据不一致问题。在真实的生产环境中,这类问题可能会造成非常严重的后果。

-- Update the embedding column for the documents table
UPDATE documents SET embedding = openai_embedding(content) WHERE length(embedding) = 0;

-- Create an index on the embedding column
CREATE INDEX ON documents USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);

-- Query the similar embeddings
SELECT * FROM documents ORDER BY embedding <-> openai_embedding('hello world') LIMIT 5;

相反,如果所有操作都在通用数据库中完成,用户体验可能会比使用独立向量数据库更加简单。在通用数据库中,向量只是一种普通的数据类型,而不是一个独立的系统。这样一来,数据一致性就不再是问题了。

第二个问题是查询语言。向量数据库的查询语言通常是专门针对向量搜索设计的,因此在其他类型的查询上可能存在诸多限制。例如,在元数据过滤(Metadata Filtering)场景下,用户需要根据某些元数据字段进行过滤,而部分向量数据库支持的过滤操作符非常有限。

此外,它们支持的元数据类型通常也很受限,往往只包括字符串(String)、数值(Number)、字符串列表(List of Strings)和布尔值(Booleans)。这对于复杂的元数据查询非常不友好。

如果传统数据库能够支持向量数据类型,那么上述问题就迎刃而解了。首先,作为生产环境中已有的基础设施,TP 或 AP 数据库天然保障了数据一致性;其次,查询语言的问题也不复存在,因为向量数据类型只是数据库中的一种数据类型,针对向量数据的查询可以直接使用数据库的原生查询语言(如 SQL)。

深入剖析

当然,只挑向量数据库的缺点是不公平的。支持专用向量数据库通常有以下几个主要论点:

  • 易用性:向量数据库在设计之初就充分考虑了易用性,用户可以轻松上手而无需关心底层实现细节。不过,正如前文所述,将它们与其他数据存储系统集成仍然是一个挑战。

  • 性能:在某些特定场景下,向量数据库相比传统数据库具有显著的性能优势。它们专为向量搜索而设计,能够在大规模高维向量数据集上实现快速、高效的相似度检索。

  • 元数据过滤:虽然向量数据库的元数据过滤能力有所限制,但仍能满足大多数常规业务场景的需求。不过,对于更复杂的元数据查询,可能需要采用混合方案,即将元数据存储在独立的数据库或数据湖中,并与向量数据库中的向量数据进行关联。

该如何看待并应对这些问题呢?在接下来的章节中,我将通过逐一回答这些问题来分享我的观点。

向量数据库确实易于使用

虽然向量数据库确实很容易上手,但这并非它们的专利。向量数据库的易用性主要源于其对特定领域的抽象,使其能够针对机器学习领域最常用的编程语言 Python 进行专门设计,并针对向量搜索场景进行优化。然而,如果传统数据库也支持向量数据类型,它们完全可以提供同等的易用性。

此外,传统数据库既可以提供 Python SDK 和其他集成工具来满足大多数场景的需求,也可以提供标准的 SQL 接口来处理更复杂的查询场景。因此,完全没有必要仅仅为了易用性而专门引入一个向量数据库。

向量数据库的另一个优势是其分布式设计,允许水平扩展以满足用户的数据量和 QPS 需求。但传统数据库同样可以通过分布式架构来满足这些需求。无论如何,是否采用分布式系统应当基于数据量和 QPS 的实际需求以及相关成本来决定。

总而言之,虽然向量数据库有其优势,但如果传统数据库支持向量数据类型,同样可以提供类似的易用性和分布式扩展能力。因此,在向量数据库与传统数据库之间做选择,应当基于应用程序的具体需求和现有资源状况。

向量数据库的性能更好吗?

为了探究向量数据库在 LLM 场景下的性能表现,有人进行了一项向量检索的基础基准测试。该基准测试包含 N 个随机初始化的 256 维向量,并测量了在不同规模 N 下查询 Top-5 最近邻所需的时间。测试采用了两种不同的方法:

  • 使用 Numpy 进行**实时(real-time)**计算,执行完全精确、非预计算的最近邻计算。

  • 使用 Hnswlib **预计算近似(approximate)**最近邻。

基准测试结果显示,在 100 万向量的规模下,使用 Numpy 进行实时精确计算的延迟约为 50 毫秒。以此为基准,我们可以对比完成向量搜索之后 LLM 推理所花费的时间。例如,一个 7B 模型在 Nvidia A100(40GB)上推理生成 300 个汉字大约需要 10 秒。因此,即使算上使用 Numpy 对 100 万向量进行实时精确相似度计算的查询时间,它也仅占端到端 LLM 推理总延迟的 0.5%。由此可见,在当前的 LLM 场景中,单就延迟而言,向量数据库带来的收益很可能会被 LLM 自身的推理延迟所掩盖。那么我们还需要考虑吞吐量(Throughput)——而 LLM 的吞吐量远低于向量数据库,因此我认为吞吐量也不是该场景下的核心瓶颈。

如果极致性能并非首要矛盾,那么决定用户选择的因素会是什么?我认为是整体的易用性——包括开发使用和日常运维的便捷性、数据一致性,以及对各类数据库共性问题的成熟解决方案。传统数据库在这些方面拥有久经考验的成熟方案,而专用向量数据库仍处于发展的早期阶段。

元数据过滤能满足大多数业务场景吗?

在考虑元数据过滤时,需要注意的不仅是支持多少种操作符,数据一致性同样是一个关键因素。向量的元数据本质上就是传统数据库中的业务数据,而向量本身则是这些数据的索引表示。因此,将向量和元数据一同存储在传统数据库中是非常合理的思路。

传统数据库完全有能力支持向量数据类型,并提供与向量数据库相当的易用性和分布式能力。更重要的是,传统数据库拥有成熟的机制来保障数据一致性与完整性,例如事务管理以及数据备份与恢复。

传统数据库中的向量支持

既然我们将向量视为传统数据库中的一种新数据类型,那么不妨以 PostgreSQL 为例,看看如何在传统数据库中支持向量数据类型。pgvector 是一个支持向量数据类型的开源 PostgreSQL 插件。pgvector 默认使用精确计算,但也支持构建 IVFFlat 索引并使用 IVFFlat 算法预计算 ANN 结果,通过牺牲一定的计算精度来换取性能。

pgvector 在支持向量方面做得非常出色,并已被 supabase 等产品采用。然而,它当时支持的索引算法较为有限,仅支持最简单的 IVFFlat 算法,且未实现量化或存储优化。此外,pgvector 的索引算法对磁盘不够友好,主要是为内存使用场景设计的。因此,专为磁盘设计的向量索引算法(如 DiskANN)在传统数据库生态中同样具有极高的价值。

由于 pgvector 是用 C 语言实现的,对其进行功能扩展颇具挑战。尽管已经开源两年,pgvector 当时仅有三位贡献者。虽然 pgvector 的实现并不算特别复杂,但使用 Rust 重写它无疑是一个值得考虑的方向。

使用 Rust 重写可以让代码组织更加现代化、更具可扩展性。Rust 的生态系统也非常丰富,已有诸如 faiss-rs 这样的现成绑定库。

正因如此,pgvecto.rs 应运而生。pgvecto.rs 支持精确向量查询操作和三种距离计算操作符,并且围绕索引支持开展了大量设计与实现工作。除了 IVFFlat 之外,我们还希望支持更多索引算法,如 DiskANN、SPTAG 和 ScaNN。我们非常欢迎来自社区的贡献与反馈!

pgvecto.rs 提供了现代化且可扩展的代码库,具备更优的性能和并发能力。其设计与实现允许与其他机器学习库和工具无缝集成,使其成为相似度搜索场景的理想选择。

随着持续的开发演进,pgvecto.rs 旨在成为数据科学家和机器学习从业者的得力工具。它对多种索引算法的支持以及出色的易用性,使其成为大规模相似度搜索应用的有力候选方案。我们期待与社区携手持续推进开发与贡献。

-- call the distance function through operators

-- square Euclidean distance
SELECT array[1, 2, 3] <-> array[3, 2, 1];
-- dot product distance
SELECT array[1, 2, 3] <#> array[3, 2, 1];
-- cosine distance
SELECT array[1, 2, 3] <=> array[3, 2, 1];

-- create table
CREATE TABLE items (id bigserial PRIMARY KEY, emb numeric[]);
-- insert values
INSERT INTO items (emb) VALUES (ARRAY[1,2,3]), (ARRAY[4,5,6]);
-- query the similar embeddings
SELECT * FROM items ORDER BY emb <-> ARRAY[3,2,1] LIMIT 5;
-- query the neighbors within a certain distance
SELECT * FROM items WHERE emb <-> ARRAY[3,2,1] < 5;

未来展望

随着大语言模型(LLM)逐步走向生产环境,底层基础设施的要求变得越来越高。向量数据库的出现是对基础设施的重要补充。我们并不认为向量数据库会取代传统数据库,而是相信它们会在不同的场景中各自发挥所长。同时,向量数据库的兴起也将推动传统数据库更好地支持向量数据类型。

我们希望 pgvecto.rs 能够成为 Postgres 生态系统的重要组成部分,为 Postgres 提供更强大的向量支持。它基于 Rust 的实现以及对多种索引算法的支持,使其在大规模相似度搜索应用中充满潜力。我们相信,通过持续研发与社区贡献,它将成为数据科学家与机器学习从业者手中极具价值的工具。

17 - 在 PostgreSQL 中使用 ColBERT 重排序为向量搜索赋能

通过本篇详尽教程,学习如何结合 PostgreSQL 与 VectorChord 使用 ColBERT 重排序来提升向量搜索准确度

传统的向量搜索方法通常采用句子级嵌入(sentence embeddings)来定位相似内容。然而,通过对词元嵌入(token embeddings)进行池化来生成句子嵌入,可能会牺牲词元级别所蕴含的细粒度细节。ColBERT 通过将文本表示为词元级多向量(multi-vectors)而非单个聚合向量,克服了这一局限。这种方法利用词元级的上下文后期交互(contextual late interaction),使 ColBERT 相比仅依赖句子嵌入的方法能够保留更微妙的语义信息,并提高搜索准确度。

ColBERT 结构(引自原论文)

如上图所示,ColBERT 将每个文档/查询编码为一组词元向量列表,并在查询时计算最大相似度(MaxSim)。

词元级后期交互需要更多的计算资源和存储空间。这使得在大规模数据集上直接使用 ColBERT 搜索极具挑战性,尤其是在对低延迟有较高要求的场景下。

一种可行的解决方案是将句子级向量搜索与词元级后期交互重排序(rerank)相结合,从而兼顾近似向量搜索的高效率与多向量相似度搜索的高质量。

多向量方法不仅局限于纯文本检索任务,还可以应用于视觉文档理解。在多模态检索模型中,诸如 ColPali 和 ColQwen 等前沿模型直接将文档图像编码为多向量,展现出比传统“OCR 转文本”方法更强大的性能。

本文将演示如何结合 PostgreSQL 扩展 VectorChord 和 pgvector 来实现 ColBERT 重排序。

教程

假设我们已经准备好了文档数据,首先创建一个表来存储这些文档:

import psycopg
from pgvector.psycopg import register_vector

class PgClient:
    def __init__(self, url: str, dataset: str, sentence_emb_dim: int, token_emb_dim: int):
        self.dataset = dataset
        self.sentence_emb_dim = sentence_emb_dim
        self.token_emb_dim = token_emb_dim
        self.conn = psycopg.connect(url, autocommit=True)
        with self.conn.cursor() as cursor:
            cursor.execute("CREATE EXTENSION IF NOT EXISTS vchord CASCADE;")
        register_vector(self.conn)

    def create(self):
        with self.conn.cursor() as cursor:
            cursor.execute(
                f"CREATE TABLE IF NOT EXISTS {self.dataset}_corpus "
                "(id INT BY DEFAULT AS IDENTITY PRIMARY KEY, text TEXT, "
                f"emb vector({self.sentence_emb_dim}), embs vector({self.token_emb_dim})[]);"
            )

这里我们创建了一张同时包含句子级嵌入和词元级嵌入数组的表。

市面上有许多嵌入 API 和开源模型,您可以根据具体业务场景选择合适的模型。

对于词元级嵌入:

from colbert.infra import ColBERTConfig
from colbert.modeling.checkpoint import Checkpoint

class TokenEncoder:
    def __init__(self):
        self.config = ColBERTConfig(doc_maxlen=220, query_maxlen=32)
        self.checkpoint = Checkpoint(
            "colbert-ir/colbertv2.0", colbert_config=self.config, verbose=0
        )

    def encode_doc(self, doc: str):
        return self.checkpoint.docFromText([doc], keep_dims=False)[0].numpy()

    def encode_query(self, query: str):
        return self.checkpoint.queryFromText([query])[0].numpy()

ColBERT 模型默认生成 128 维的向量。

插入数据:

class PgClient:
    ...
    def insert(self, documents: list[str]):
        with self.conn.cursor() as cursor:
            for doc in tqdm(documents):
                sentence_emb = sentence_encoder.encode_doc(doc)
                token_embs = [emb for emb in token_encoder.encode(doc)]
                cursor.execute(
                    f"INSERT INTO {self.dataset}_corpus (text, emb, embs) VALUES (%s, %s, %s)"
                    (doc, sentence_emb, token_embs)
                )

在向量搜索部分,我们可以使用 VectorChord 构建高性能的 RaBitQ 索引:

class PgClient:
    ...
    def index(self, num_doc: int, workers: int):
        n_cluster = 1 << math.ceil(math.log2(num_doc ** 0.5 * 4))
        config = f"""
        residual_quantization = true
        [build.internal]
        lists = [{n_cluster}]
        build_threads = {workers}
        spherical_centroids = false
        """
        with self.conn.cursor() as cursor:
            cursor.execute(f"SET max_parallel_maintenance_workers TO {workers}")
            cursor.execute(f"SET max_parallel_workers TO {workers}")
            cursor.execute(
                f"CREATE INDEX {self.dataset}_rabitq ON {self.dataset}_corpus USING "
                f"vchordrq (emb vector_l2_ops) WITH (options = $${config}$$)"
            )

为了加快索引构建过程,我们可以利用外部质心构建功能。详情请参阅《VectorChord 外部质心构建的优势与步骤》。

现在,我们可以在 PostgreSQL 中执行查询:

class PgClient:
    ...
    def query(self, doc: str, topk: int):
        sentence_emb = sentence_encoder.encode_query(doc)
        with self.conn.cursor() as cursor:
            cursor.execute(
                f"SELECT id, text FROM {self.dataset}_corpus ORDER BY emb <-> %s LIMIT {topk}"
            )
            res = cursor.fetchall()
        return res

为了支持 MaxSim 重排序,我们需要创建一个 SQL 函数:

class PgClient:
    def __init__(self, url: str, dataset: str, sentence_emb_dim: int, token_emb_dim: int):
        ...
        self.conn.execute("""
            CREATE OR REPLACE FUNCTION max_sim(document vector[], query vector[]) RETURNS double precision AS $$
            WITH queries AS (
                SELECT row_number() OVER () AS query_number, * FROM (SELECT unnest(query) AS query)
            ),
            documents AS (
                SELECT unnest(document) AS document
            ),
            similarities AS (
                SELECT query_number, document <=> query AS similarity FROM queries CROSS JOIN documents
            ),
            max_similarities AS (
                SELECT MAX(similarity) AS max_similarity FROM similarities GROUP BY query_number
            )
            SELECT SUM(max_similarity) FROM max_similarities
            $$ LANGUAGE SQL
        """)

接下来,我们就可以对向量搜索召回的候选文档进行重排序了:

class PgClient:
    def rerank(self, query: str, ids: list[int], topk: int):
        token_embs = [emb for emb in token_encoder.encode_query(query)]
        with self.conn.cursor() as cursor:
            cursor.execute(
                f"SELECT id, text FROM {self.dataset}_corpus WHERE id = ANY(%s) ORDER BY "
                f"max_sim(embs, %s) DESC LIMIT {topk}"
                (ids, token_embs)
            )
            res = cursor.fetchall()
        return res

评估

我们在多个 BEIR 数据集上测试了该方法,结果如下:

数据集 搜索 NDCG@10 重排序 NDCG@10
fiqa 0.23211 0.3033
quora 0.31599 0.3934

结果表明,ColBERT 重排序能够显著提升向量搜索的结果质量。

所有相关的基准测试代码均可在此处找到。

未来计划

将向量搜索、全文检索与 ColBERT 重排序相结合,可以进一步提升检索效果。我们目前也在开发 PostgreSQL BM25 扩展,敬请期待。

参考资料

18 - ARM + VectorChord:高性价比的快速向量搜索

像 AWS Graviton 这样的 ARM64 服务器能显著节省成本。VectorChord 比 pgvector 便宜 26 倍,针对 ARM64 和 x86 深度优化,提供快速、高效的向量搜索体验。

ARM64 正悄然成为高性价比云服务器的首选架构,尤其是在 AWS 上。借助 Graviton 系列(Graviton3 和 Graviton4)等基于 ARM 的处理器,你可以在不牺牲性能的前提下节省一大笔开销。这些 ARM64 实例比传统的 x86 实例实惠得多,是在云端平衡成本与性能的可靠选择。

话虽如此,由于 ARM 的架构与 x86 不同,未针对其进行优化的软件可能会因为指令模拟或缺乏优化而运行缓慢。这正是 VectorChord 大显身手的地方——它是一款专为可扩展、高性能且磁盘高效的向量相似度搜索而设计的 PostgreSQL 扩展。作为 pgvecto.rs 的继任者,VectorChord 让你仅花 $1 就能存储多达 400,000 个向量,带来惊人的成本节省:在相同价格下,可存储的向量数量是 Pinecone 优化存储的 6 倍,更是 pgvector / pgvecto.rs 的 26 倍。

通过充分利用平台特定的硬件特性(如 x86_64 上的 AVX-512 和 ARM64 上的 SVE),VectorChord 确保了在不同系统架构下都能提供高效的向量搜索性能。无论你使用的是 ARM64 还是 x86,VectorChord 都能让系统流畅运行,是现代计算环境中可靠且通用的解决方案。简而言之,ARM64 是省钱的绝佳选择,而 VectorChord 则确保你的向量搜索既快速、高效,又经济实惠。

基准测试环境

我们在三款 AWS EC2 实例上运行了基准测试,以评估 VectorChord 在不同硬件配置下的性能表现。以下是我们的实例选型与对比参数:

实例类型 i4i.xlarge i8g.xlarge i7ie.xlarge
内存 32 GiB 32 GiB 32 GiB
vCPU 4 4 4
CPU 代数 第 3 代 Intel Xeon AWS Graviton4 第 5 代 Intel Xeon
CPU 架构 x86_64 arm64 x86_64
存储 937 GB NVME SSD 937 GB NVME SSD 2500 GB NVME SSD
价格 $0.1158 / 小时 $0.1162 / 小时 $0.1723 / 小时

基于 ARM 的 i8g.xlarge 定价几乎与 i4i.xlarge 完全相同,但在 CPU 性能上带来了显著提升。而与 CPU 能力相当的 i7ie.xlarge 相比,i8g.xlarge 的价格要便宜近三分之一(后者贵约 50%),是更具性价比的选择。

测试结果

为了展示 ARM64 机器在向量搜索领域的竞争力,我们在所有选定的机型上运行了相同的 LAION 基准测试。我们测量了不同搜索参数下的索引构建速度、查询吞吐量(QPS)以及召回率等关键指标。

该基准测试包括插入 500 万个向量(每个向量为 768 维)并在其上运行 1,000 次查询。下方结果重点展示了各个实例的索引构建时间和查询性能,清晰地呈现了 ARM64 与传统 x86 配置的正面对比。

如结果所示,运行在 i8g.xlarge(ARM64)上的 VectorChord 与价格相近的 i4i.xlarge 相比,吞吐量提升了约 30%。另一方面,价格高出 50% 的 i7ie.xlarge 相比 i8g 仅提供了小幅的 15% 性能提升——远不足以证明其高昂价格的合理性。此外,基于 ARM 的 i8g 上的索引构建时间也明显更快,这是一个令人惊喜且深受欢迎的优势。

总而言之,无论与 i4i.xlarge 还是 i7ie.xlarge 相比,基于 ARM 的 i8g.xlarge 都在性能与成本之间取得了更好的平衡。这与 VectorChord 为用户带来的核心价值不谋而合:以更低的成本获得更高的性能。无论你是希望节省开支还是最大化系统效率,ARM64 与 VectorChord 的组合都是当之无愧的赢家。

19 - 为什么 HNSW 不是终极答案

HNSW(分层可导航小世界图)已成为众多向量数据库的首选算法。其多层图结构以及高效遍历向量嵌入的能力使其备受青睐……

HNSW(Hierarchical Navigable Small World,分层可导航小世界图)已成为众多向量数据库的首选算法。其多层图结构以及高效导航向量嵌入的能力使其格外具有吸引力。然而,尽管拥有诸多显著优势,对于大规模且动态变化的向量相似度搜索而言,HNSW 可能并非最优解。在本文中,我们将挑战 HNSW 的主导地位,并探讨为什么基于磁盘的替代方案——例如 IVF(Inverted File Index,倒排文件索引)——在面对海量数据集时更具实用价值。


HNSW 的吸引力

HNSW 具有以下几项优势:

  • 搜索高效:其基于图的结构能够实现快速的最近邻搜索,在小规模数据集上表现尤为突出。

  • 增量更新:无需重建整个索引即可增量添加新向量,这对于动态环境而言是一大优势。

  • 高召回率:HNSW 能够在相对较低的延迟下提供高召回率,使其成为实时应用的理想选择。

然而,这些优势背后伴随着一系列权衡,并且随着数据集规模的扩大,这些问题会变得愈发突出。


HNSW 的局限性

HNSW 面临的核心挑战之一,是它在索引构建和搜索操作中对内存的高度依赖。以下是由此引发的主要问题:

  • 高昂的内存开销:遍历 HNSW 的图结构涉及高度随机的内存访问模式。为了获得理想的性能,必须将整个数据集保存在内存中。面对包含数十亿高维向量的大规模数据集,天价的内存需求使得这一方案根本无法落地。

  • 性能对内存大小极其敏感:一旦可用内存哪怕略微不足以容纳全部向量,HNSW 的性能就会断崖式下跌。在这种情况下,将数据交换(swap)到磁盘会严重拖慢搜索速度,使其在内存受限的系统中几乎不可用。

  • 不适合基于磁盘的环境:HNSW 从根本上就是为纯内存操作而设计的,在面向磁盘的场景(例如 PostgreSQL)中表现不佳。它对频繁随机访问的依赖,与磁盘存储擅长顺序访问的特性格格不入。

  • 插入与删除代价高昂:在 HNSW 中更新索引会触发整个图结构的级联修改,导致大量的计算开销与写放大(write amplification)。这使得插入和删除操作既缓慢又消耗资源。

相比之下,像 IVF 这样基于磁盘的解决方案,在需要兼顾可扩展性、高效率与低复杂度的场景中表现得尤为出色。


为什么 IVF 可以比 HNSW 更快

https://www.researchgate.net/publication/357875438/figure/fig15/AS:1113078611607560@1642390029247/Two-state-of-the-art-ANN-indexing-methods-the-IVF-index-Left-and-the-three-layer.ppm

纵观所有向量搜索算法,一个关键规律是:算法的性能在很大程度上取决于它所执行的距离计算次数。最大限度地减少这些计算对于提升搜索速度至关重要。尽管原始的 IVF 索引需要扫描总向量数的 1% 到 5%,但现代量化技术与扫描优化的进步已经大幅提升了它的效率,使 IVF 成为 HNSW 的强劲对手。

量化技术:改变游戏规则的关键

量化(Quantization)可将高维向量压缩为紧凑的表示形式。例如:

  • RaBitQ:利用测度集中(concentration of measure)现象来提升二值量化与标量量化的准确度,将每个维度量化为 1 比特,实现 32 倍的压缩比。

  • 乘积量化(Product Quantization, PQ):将向量空间划分为多个子空间,并对每个子空间独立进行量化以最小化近似误差。该方法在 FAISS 中提供了 4 倍至 64 倍的灵活压缩比,支持更精细的压缩和更快速的近似搜索。

  • 标量量化(Scalar Quantization, SQ):通过将每个向量维度的取值范围划分为离散级别来独立量化各个维度,通常通过将浮点数(float)转换为 8 位整数(int8)来实现 4 倍的压缩比。

  • ScaNN:采用各向异性向量量化(anisotropic vector quantization),通过对影响高内积方向上的误差施加更大惩罚来优化内积准确度,从而实现优异的召回率与速度。

量化技术能够大幅降低内存和磁盘空间占用——将 32 位浮点数转换为比特时通常可缩减 32 倍——同时显著降低计算开销。尽管会带来一定的精度损失,但量化让向量距离比较变得高效得多。例如,将浮点数压缩为比特后,计算复杂度降低了 32 倍;此外,位运算通常比浮点运算高效得多,从而进一步提升了性能。结合快速扫描(fast-scan)优化,还可以通过高效的 CPU 寄存器查表进一步加速这些计算。当与 IVF 结合时,许多量化方法在速度和可扩展性上都能稳定超越 HNSW。其典型工作流程包括:

  1. 初始搜索(Initial Search):利用压缩表示快速筛选出候选向量。

  2. 重排序(Reranking):针对规模小得多的候选子集,使用全精度距离计算对结果进行精排。

RaBitQ+IVF 与 HNSW 对比

结合量化的 IVF 提供了一种将所有量化后向量保存在内存中的高效方式。借助 RaBitQ,内存占用相比全精度向量减少了 32 倍,从而可以将整个量化数据集轻松装入内存。这种设计使索引能够利用比特向量快速检索近似最近邻,并从磁盘读取全精度向量对其进行重排序。对于典型的 Top-10 查询,IVF 只需从磁盘读取 100–200 条向量,而 HNSW 则可能需要读取 800–1000 条向量。这种高效的方法在内存占用与磁盘访问之间实现了最佳平衡,带来了卓越的性价比。

虽然理论上类似的量化技术也可以应用于 HNSW,但实际限制削弱了它们在 HNSW 上的效果:

  • 向量打包(Vector Packing):快速扫描优化依赖于将 32 条压缩向量打包在一起,这与 HNSW 的图结构不兼容。

  • 随机访问开销:HNSW 涉及跨图节点和边的频繁随机访问,导致遍历效率低下。相比之下,IVF 将向量按顺序组织在倒排列表(posting lists)中,支持更快的预取(prefetching)和高效的顺序扫描。

  • 重排序开销:由于第一阶段都依赖量化表示,IVF 和 HNSW 在重排序阶段的计算开销相似。

归根结底,量化 IVF 与 HNSW 之间的极限性能差异微乎其微,但 IVF 凭借其简洁性和资源效率脱颖而出。


特性 IVF IVF + RaBitQ HNSW
索引方法 KMeans 可卸载至 GPU KMeans + 量化 最近邻图,需要将所有数据保存在内存中
跨查询共享的热点数据 聚类质心(Centroids) 量化后的向量 无
可扩展性 受限于 CPU 和内存 极其出色 受限于内存
插入/删除 简单(仅更新倒排列表) 简单(仅更新倒排列表) 复杂(触发图结构级联修改)
搜索速度 慢 结合量化后极快 内存充足时快
整体复杂度 低 低 高

IVF 的运维简洁性

IVF 的简洁性使其成为真实生产应用中更具实用价值的选择:

  • 插入与删除:在 HNSW 中,这些操作会触发整个图结构的级联修改,导致大量的计算开销与写放大。相比之下,IVF 只需要更新相应的倒排列表即可。

  • 基于磁盘的存储:IVF 依托基于磁盘的索引机制,无需承担 HNSW 高昂的内存成本即可实现高效扩展。

  • 适应性强:IVF 可以轻松与先进的量化技术相结合,从而实现更深层次的性能优化。


结论

虽然 HNSW 已经确立了其作为快速、准确的向量相似度搜索算法的声誉,但它并非没有局限性。其内存密集型的本质和较高的运维复杂度,使其不太适合超大规模应用场景。相比之下,IVF——尤其是与现代量化技术结合时——提供了一种可扩展且高性价比的替代方案。

随着向量搜索需求的持续增长,从业者必须审慎评估基于内存与基于磁盘的解决方案之间的权衡。HNSW 或许仍将在中小规模应用中占据主导地位,但面对海量数据集,是时候跳出 HNSW 的局限,拥抱像 IVF 这样更简洁、更具可扩展性的方案了。

如果您对这类数据库感兴趣,欢迎了解并试用 VectorChord。

%[https://github.com/tensorchord/VectorChord/]

20 - VectorChord 外部聚类中心构建的优势与操作步骤

利用外部计算资源与层次化 K-means 在 VectorChord 中进行外部聚类中心构建,显著提升大规模向量搜索的索引效率

聚类(Clustering)是向量搜索中的重要组成部分,而 K-means 算法依然是目前应用最广泛的技术之一。然而,它的简洁性也伴随着一个显著的缺点:密集的计算资源消耗使其在处理大规模数据集时往往力不从心。本文将探讨这一挑战产生的原因,并介绍如何利用外部构建(External Builds)来克服它,同时分享我们在实现这一方案过程中的技术洞察。

为什么需要外部构建

VectorChord 使用 IVF + RaBitQ 来执行向量搜索。IVF 聚类簇的质量会直接影响搜索精度和速度。K-means 通常需要迭代数十轮才能收敛,而在每一轮中,所有采样的向量都需要被分配到距离最近的聚类簇中。这是计算最密集的环节,不过好消息是,这一过程非常容易并行化。

然而,承载向量搜索服务的数据库实例通常没有富余的计算资源来执行这种大规模并行计算;如果强行在数据库实例上运行,会严重影响线上向量搜索服务的稳定性。在这种情况下,利用外部计算实例来加速索引构建阶段便成为了顺理成章的选择。

外部 K-means 聚类

源向量数据的总大小可能是实例内存的数倍。为了避免内存溢出(OOM),我们可以在聚类之前先对向量进行采样。聚类簇数量(nclustern_{cluster})的推荐选择范围为:

4∗nvec≤ncluster≤8∗nvec 4 * \sqrt{n_{vec}} \le n_{cluster} \le 8 * \sqrt{n_{vec}}

通常,我们最多只需要采样 256 * n_cluster 个向量。如果数据无法全部装入内存,可以通过水塘抽样(Reservoir Sampling)来完成采样。

如果你有现成可用的 GPU 环境,它的运行速度可以比 CPU 实例快上数十倍甚至数百倍。否则,你可能会发现一半甚至大部分时间都花在了启动实例、传输数据和采样上,而不是真正的计算上。

下面的聚类实验是在一台运行 faiss-gpu (max_iter=25) 的 H100 SXM5 实例(成本为 $3.29 / 小时)与一台运行 faiss-cpu (max_iter=25) 的 i4i.8xlarge 实例(成本为 $2.746 / 小时)上进行的对比。算下来,使用 CPU 实例进行聚类的总成本大约是 GPU 实例的 60 倍。

数据集 Cohere (1024 × 10m) MS MARCO (768 × 100m)
n_cluster 16384 65536
GPU K-means 耗时 80s 960s
CPU K-means 耗时 5850s 68420s
提示

敬请期待我们即将推出的云端外部索引构建支持。

显而易见,更大的 n_cluster 会导致计算时间呈指数级增长。此外,当 n_cluster 很大时,普通的 K-means 也很难維持各个聚类簇之间的相对平衡。

克服这一问题的好方法是采用层次化 K-means(Hierarchical K-means)。通常 2 层结构就已经足够。我们可以构建一个 n_cluster = [40, 100] 的层次化 K-means,而不是直接构建 n_cluster = 4000 的扁平 K-means。这种方式将繁重的计算转化为了 I/O 和轻量级计算,当你希望使用 CPU 实例处理大规模数据集时,这是一个非常理想的选择。

操作步骤

假设向量数据已经存储在 VectorChord 中,我们需要执行以下步骤:

  1. 导出向量并将其上传至 S3

  2. 启动外部计算实例来运行 K-means 聚类

  3. 将生成的聚类中心(Centroids)插回 VectorChord

  4. 使用外部聚类中心表构建索引

我们提供了一些脚本,方便你快速上手:

外部构建文档

你可以克隆代码仓库并亲自尝试:

gh repo clone tensorchord/VectorChord
cd VectorChord/scripts
# export embeddings to a h5 file
python dump.py -n [table name] -c [column name] -d [dim] -o export.hdf5
# upload to s3
aws s3api put-object --bucket external-centroids --key export.hdf5 --body export.hdf5
# download in the CPU/GPU worker
aws s3api get-object --bucket external-centroids --key export.hdf5 export.hdf5
# run K-means with CPU
python train.py -i export.hdf5 -o centroid.npy -lists [lists]
# or with GPU
python train.py -i export.hdf5 -o centroid.npy -lists [lists] -g
# insert to the centroids table & build the index
python index.py -n [table name] -i export.hdf5 -c centroid.npy -d [dim]

总结

综上所述,在 VectorChord 中利用外部聚类中心构建,为处理向量搜索中的大规模数据集提供了显著优势。通过利用外部资源执行计算密集型的 K-means 聚类,我们不仅能够提升搜索过程的效率与精度,还能有效缓解向量搜索数据库实例的资源压力,并在使用 GPU 环境时大幅缩短处理时间。结合层次化 K-means 可以进一步平衡计算与 I/O 操作,使得使用 CPU 实例处理超大规模数据集也同样切实可行。

21 - VectorChord:在 PostgreSQL 中仅花 $1 存储 40 万向量

隆重推出 pgvecto.rs 的继任者 VectorChord。使用 VectorChord,您可以在 1 亿条 768 维向量数据集上以 0.95 的精度实现 131 QPS

我们很高兴地宣布推出全新的 PostgreSQL 向量搜索扩展,为大规模向量管理提供极具性价比的解决方案。使用 VectorChord,您可以在 1 亿条 768 维向量数据集上执行 Top 10 查询时,以 0.95 的精度实现 131 QPS。整套环境可在单台机器上运行,每月成本仅为 $250。

这意味着您只需花费 $1 即可存储 40 万条向量,从而大幅节省成本:在同等价格下,可存储的向量数量是 Pinecone(存储优化型实例)的 6 倍,更是 pgvector/pgvecto.rs 的 26 倍。

基于 MyScale 基准测试数据的向量存储月度成本对比图清晰地显示,VectorChord 是极具性价比的选择,存储 1 亿条向量每月仅需 $247。相比之下,即便采用了优化存储,Pinecone 每月仍需 $1,600,Qdrant 定价为 $4,374,而 pgvector/pgvecto.rs 的成本更是高达 $6,580。

%[https://github.com/tensorchord/VectorChord]

HNSW 的痛点

作为 pgvecto.rs 的继任者,VectorChord 从前代产品中汲取了宝贵的经验。虽然许多向量数据库或扩展(包括 pgvecto.rs)在 100 万左右的数据集规模下表现良好,但当规模扩展到更大体量(例如从 1,000 万增长到 1 亿)时,往往会难以为继。传统的基于 HNSW 的向量数据库在面对大规模数据集时面临着以下具体挑战:

  • 漫长的索引构建时间:为 500 万条记录构建索引通常需要超过 2 小时。

  • 高昂的内存需求:存储一个包含 1,000 万条向量的数据集可能需要高达 40GB 的内存。

VectorChord 的解决方案:磁盘友好的 IVF+RaBitQ

VectorChord 采用 IVF(倒排文件索引)结合 RaBitQ

11

量化技术,提供快速、可扩展且准确的向量搜索能力。该方法将 32 位浮点向量压缩为紧凑的比特表示,显著降低了计算开销。绝大部分距离比较都在这些压缩向量上进行,而全精度计算仅保留给针对小规模候选集的自适应重排序(reranking)阶段,从而同时兼顾了速度与召回率。

许多人认为 IVF 在召回率/速度权衡上不如 HNSW,且涉及繁琐的参数调优配置。实际上这是一个较为复杂的话题,我们在此先做简要说明,后续会发布专文进行深度剖析。

IVF 与 HNSW 对比

向量搜索算法的很大一部分耗时都花在距离计算上。为了提升速度,必须尽可能减少距离比较的次数。原始的 IVF 在这方面表现欠佳,通常需要扫描总向量数的 1% 到 5%,远高于 HNSW 所需的扫描量。

然而,RaBitQ 提出了一种创新方法,能够将 32 位浮点数的每个维度压缩至仅需 1 比特。虽然这种压缩会带来一定的精度损失,但它大幅降低了计算需求。结合快速扫描(fast scan)优化,我们可以实现比传统向量距离计算快 100 倍以上的计算速度。

您可能会担心召回率问题。我们可以通过对更多候选向量进行重排序来提高召回率,而全精度距离计算仅在重排序阶段才需要执行。RaBitQ 不仅保证了紧凑的理论误差界,同时在实测中也具备优秀的准确度。这正是 IVF 能够比 HNSW 更快的原因所在。

以下是我们在 GIST 数据集(包含 100 万条 960 维向量)上的初步基准测试结果。在相同召回率下,VectorChord 的 QPS 可以达到 pgvector 的两倍。更多细节将在下文的基准测试章节中展示。

外部索引构建

原始的 IVF 方法通常需要扫描数据集的 1%–5%,速度较慢。通过使用 RaBitQ 和快速扫描优化,VectorChord 大幅减少了需要执行全精度比较的向量数量,从而显著加快了计算速度。这种方法有助于打造一个稳定、可扩展且与 PostgreSQL 存储系统良好契合的向量搜索系统。因此,用户可以将 PostgreSQL 的物理复制等原生特性与 VectorChord 无缝结合使用。

由于构建在 IVF 之上,VectorChord 允许在数据库外部(例如在 GPU 上)执行 KMeans 聚类,随后将结果轻松导入数据库。我们在 AWS i4i.large 实例(配备 2 vCPU 和 16 GB 内存)上进行了测试,以评估索引构建和插入耗时。测试使用的数据集为 GIST 1M:我们先插入 700,000 条向量并构建索引,然后再追加插入 300,000 条向量。在系统预热后,我们使用单线程执行查询,并在此过程中评估了索引构建时间和插入时间,结果如下:

通过利用独立机器执行 KMeans 聚类,VectorChord 仅需 186 秒即可完成索引构建,速度比 pgvector 快 16 倍。此外,其数据插入速度也比 pgvector 快 14 倍。众所周知,构建索引是向量数据库中最耗费资源的环节,需要大量计算并推高对 CPU 和内存的需求。通过利用算力更强的机器构建索引,再将其导入较小的机器提供查询服务,在单台机器上支撑数十亿规模的向量便成为了现实。

基准测试

我们使用 LAION 5M 和 100M 数据集进行了更多实验,以更全面地评估性能与成本。

LAION 5M

我们在 LAION 5M 数据集上进行了实验,VectorChord 的表现令人鼓舞:与其他平台相比,它始终能够实现更高的每秒查询数(RPS)。当许多数据库在召回率提升时难以兼顾速度与精度之际,VectorChord 即便在高召回率水平下依然保持高效。这一特性使其非常适合同时需要快速响应与高精度的应用场景。

该实验采用 MyScale 单线程基准测试,在一台配备 4 vCPU、32GB 内存和 200GB EBS 存储的 r6a.xlarge 机器上运行。实验参数设置为:nlist 为 8192,shared_buffers 为 28GB,禁用 JIT,effective_io_concurrency 为 200。我们在未预热(prewarm)的情况下将实验运行了两次。

我们使用的 r6a.xlarge 机器每月成本约为 $165.56,而 Zilliz Performance 4CU 的定价约为每月 $460。值得注意的是,我们在 LAION 5M 数据集的 Top 100 查询中实现了与之相当的性能表现。

单机运行 LAION 100M

此外,得益于磁盘友好的索引设计,增加单台机器的磁盘容量即可按比例提升 VectorChord 所能容纳的最大向量数量,从而具备在单机上存储 10 亿甚至更多向量的潜力。

为了评估可扩展性,我们在 AWS i4i.xlarge 实例(每月仅需 $250 的经济型配置)上对 LAION 100M 数据集(768 维)进行了实验。

该实例仅配备 4 个 CPU 和 32 GB 内存,并使用 937 GB 的本地 SSD 来存储这 1 亿条向量。在此配置下,使用单线程查询时,我们在 Top 10 结果上实现了 16.2 QPS @ 0.95 召回率,在 Top 100 结果上实现了 4.3 QPS @ 0.95 召回率。以下是令人印象深刻的测试结果:

在保持召回率高于 0.95 的同时,我们还评估了这台 4 vCPU 机器上的多线程 QPS。在该场景下,随着并发请求线程数从 1 增加到 8,向量查询的 QPS 呈现出近似线性的增长,证明 VectorChord 具备出色的并发扩展能力。

总结

VectorChord 是一款专为高效向量搜索设计的全新 PostgreSQL 扩展。它让用户仅需花费 $1 即可存储 400,000 条向量,成本远低于同类竞争产品。通过结合 IVF 与 RaBitQ 量化技术,VectorChord 大幅优化了搜索速度与内存占用,非常适合大规模数据集场景。

我们在 PGVecto.rs Cloud 上提供 VectorChord 的云端托管服务。我们的平台简化了部署与管理流程,让您能够轻松高效地扩展向量数据库解决方案。如果您对 VectorChord 有任何疑问,欢迎随时联系我们!您可以在我们的代码仓库中提交 Issue,或发送邮件至 [email protected]。

%[https://github.com/tensorchord/VectorChord]

参考资料

11

Gao, Jianyang, and Cheng Long. “RaBitQ: Quantizing High-Dimensional Vectors with a Theoretical Error Bound for Approximate Nearest Neighbor Search.” Proceedings of the ACM on Management of Data 2.3 (2024): 1-27.

22 - 推出 PGVecto.rs Cloud:PostgreSQL 可扩展向量搜索现已上云

我们激动地宣布推出 PGVecto.rs Cloud——将可扩展向量搜索的强大能力带入你的 PostgreSQL 数据库,现已作为全托管云服务正式上线……

我们非常激动地宣布推出 PGVecto.rs Cloud——将可扩展向量搜索的强大能力带入你的 PostgreSQL 数据库,现已作为全托管云服务正式上线。

PGVecto.rs 是一款开创性的 Postgres 扩展,支持高性能向量搜索,赋能开发者在现有 Postgres 基础设施之上构建先进的相似度检索应用。借助 PGVecto.rs Cloud,你现在无需自行承担管理扩展的运维开销,即可直接释放这项变革性技术的全部潜力。

通过与现有 Postgres 基础设施无缝集成,PGVecto.rs Cloud 助你构建前沿应用,轻松实现:

  1. 革新搜索体验:借助可扩展、高性能的向量搜索能力,充分释放数据潜能,帮助用户找到最相关、最相似的结果。

  2. 增强过滤能力:支持应用任意过滤条件并与其他表进行关联(JOIN),同时兼顾高召回率与低延迟——这是相比其他专用向量数据库的独特优势。

  3. 支持超长向量维度:PGVecto.rs Cloud 支持高达 65,535 维的向量长度,能够从容应对最新机器学习模型不断增长的输出维度需求。

  4. 优化二值向量搜索:利用自适应检索(adaptive retrieval)技术,PGVecto.rs Cloud 在保持高精度的同时,可将内存占用大幅降低多达 30 倍。

  5. 无缝结合全文与向量搜索:将 Postgres 内置的全文检索功能与 PGVecto.rs Cloud 相结合,在单条查询中同时检索文本和向量数据,解锁前所未有的灵活性与表现力。

  6. 畅享卓越性能:速度比其他基于 Postgres 的解决方案快 4 倍,PGVecto.rs Cloud 提供闪电般的响应速度,确保你的应用始终保持极速体验。

  7. 拥抱完整 SQL 支持:PGVecto.rs Cloud 提供完整的 SQL 支持,可与你现有的工作流无缝集成,让你不受限制、无需额外配置地自由使用 JOIN、过滤及其他 SQL 特性。

价格方案

PGVecto.rs Cloud 提供灵活的定价方案,以满足不同企业和开发者的多样化需求。无论你是刚刚开启向量搜索之旅,还是在运行大规模生产系统,我们都有契合你需求的方案。欢迎访问我们的价格页面了解更多详情。

如果你需要企业级支持或咨询服务,请通过 [email protected] 与我们联系。我们的团队随时准备协助你挑选最合适的方案,并提供必要的指导,确保你在 PGVecto.rs Cloud 上取得成功。

23 - 为 PGVecto.rs Cloud 引入 Terraform 支持

像管理其他基础设施组件一样,使用 Terraform 管理 PGVecto.rs Cloud

我们非常高兴地宣布发布适用于 PGVecto.rs Cloud 的 Terraform Provider。这一全新 Provider 旨在简化我们云服务上的资源管理流程,让用户能够通过基础设施即代码(IaC)工具 Terraform,以直观、高效的方式管理 Postgres 集群的部署与运维。对于尚不熟悉 Terraform 的读者,它是一款开源工具,用于安全、高效地构建、变更和版本化管理基础设施。它支持用户将基础设施定义为代码,从而以编程方式调配和管理各类资源。

关于 PGVecto.rs:Postgres 中的可扩展向量搜索

PGVecto.rs 是一款 Postgres 扩展,支持可扩展的向量搜索,让你能够在 Postgres 数据库之上构建强大的相似度检索应用。

PGVecto.rs 凭借多项强大特性脱颖而出:

  • 更出色的过滤能力:支持应用任意过滤条件并与其他表进行 JOIN,在实现高召回率与低延迟的同时,展现出相比其他向量数据库的独特优势。

  • 混合搜索(Hybrid Search):结合 PostgreSQL 的全文检索功能与 pgvecto.rs,在单条查询中同时检索文本和向量数据。

  • 超长向量维度支持:PGVecto.rs 支持最高 65535 维的向量长度,非常契合最新的大模型需求。

  • 快上加快:比其他基于 Postgres 的解决方案快 4 倍。

  • 二值向量搜索:通过采用自适应检索技术,二值向量在保持高精度的同时,可将内存占用大幅降低 30 倍。

  • 完整的 SQL 支持:提供完整的 SQL 支持,无需额外配置即可不受限制地使用 JOIN 和过滤条件。

如何开始使用 Terraform 与 PGVecto.rs Cloud

本指南将带你完成 PGVecto.rs Cloud Provider 的安装与配置流程。

前置条件

在开始之前,请确保你已具备以下条件:

  1. 已安装 Terraform:请访问此处,按照说明下载并安装 Terraform。

  2. PGVecto.rs Cloud 账号:你需要拥有 PGVecto.rs Cloud 的访问权限及 API Key。请前往 PGVecto.rs Cloud 控制台创建 API Key。该 API Key 必须具备 ProjectOwner 权限。

下载 PGVecto.rs Cloud Terraform Provider

首先,在你的 Terraform 配置文件(main.tf)中配置 PGVecto.rs Cloud Provider,步骤如下:

terraform {
  required_providers {
    pgvecto-rs-cloud = {
      source = "tensorchord/pgvecto-rs-cloud"
    }
  }
}

初始化 Terraform 配置

$ terraform init --upgrade

Initializing the backend...

Initializing provider plugins...
- Finding latest version of tensorchord/pgvecto-rs-cloud...
- Installing tensorchord/pgvecto-rs-cloud v0.0.6...
- Installed tensorchord/pgvecto-rs-cloud v0.0.6 (self-signed, key ID ABEFCD2BB128AAB1)

Partner and community providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://www.terraform.io/docs/cli/plugins/signing.html

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

Terraform 会下载 pgvecto-rs-cloud Provider,并将其安装到当前工作目录下名为 .terraform 的隐藏子目录中。

为 PGVecto.rs Cloud Terraform Provider 配置认证

使用该 Terraform Provider 需要提供你的 PGVecto.rs Cloud API Key。共有两种配置方式:

方式 1:在 Provider 块中指定 API Key

将以下代码追加到你的 main.tf 文件中:

provider "pgvecto-rs-cloud" {
  api_key = "<your-api-key>"
}

方式 2:使用环境变量

将 API Key 设置为环境变量:

export PGVECTORS_CLOUD_API_KEY="<your-api-key>"

此时,你的 main.tf 文件中的 Provider 声明只需写成:

provider "pgvecto-rs-cloud" {
}

完成以上步骤后,PGVecto.rs Cloud Terraform Provider 就已配置就绪,可以继续后续操作了。

管理 PGVecto.rs Cloud 集群

创建集群实例

以下示例展示了如何创建一个 PostgreSQL 集群。关于各配置项的更多信息,请参考 Resource Schema。

你可以从云控制台获取 account_id:点击右上角的头像,然后点击 Profile(个人资料)。

resource "pgvecto-rs-cloud_cluster" "enterprise_plan_cluster" {
  account_id   = "58aa09d9-854c-4223-9920-b85b9d878e72"
  cluster_name = "enterprise-plan-cluster"
  plan = "Starter"
  server_resource = "aws-t3-xlarge-4c-16g" // The only server resource supported by the Starter plan, shared environment
  region            = "us-east-1"
  cluster_provider  = "aws"
  database_name     = "test"
  pg_data_disk_size = "10"
  enable_pooler     = true
}

output "psql_endpoint_enterprise" {
  description = "Endpoint for the PGVecto.rs Cloud Starter PostgreSQL database"
  value       = pgvecto-rs-cloud_cluster.enterprise_plan_cluster.connect_endpoint
}

你可以按以下步骤验证 Terraform 代码:

$ terraform validate && terraform plan && terraform output
Success! The configuration is valid.


Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # pgvecto-rs-cloud_cluster.enterprise_plan_cluster will be created
  + resource "pgvecto-rs-cloud_cluster" "enterprise_plan_cluster" {
      + account_id        = "58aa09d9-854c-4223-9920-b85b9d878e72"
      + cluster_name      = "enterprise-plan-cluster"
      + cluster_provider  = "aws"
      + connect_endpoint  = (known after apply)
      + database_name     = "test"
      + enable_pooler     = true
      + id                = (known after apply)
      + last_updated      = (known after apply)
      + pg_data_disk_size = "10"
      + plan              = "Starter"
      + region            = "us-east-1"
      + server_resource   = "aws-t3-xlarge-4c-16g"
      + status            = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + psql_endpoint_enterprise = (known after apply)

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
╷
│ Warning: No outputs found
│
│ The state file either has no outputs defined, or all the defined outputs are empty. Please define an output in your configuration with the `output` keyword and run `terraform refresh` for it to become available. If you are using
│ interpolation, please verify the interpolated value is not empty. You can use the `terraform console` command to assist.

接着运行以下命令生成执行计划;确认无误后即可应用该计划。应用完成后,Postgres 集群将在你的 PGVecto.rs Cloud 账号中创建完毕。

$ terraform apply

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # pgvecto-rs-cloud_cluster.enterprise_plan_cluster will be created
  + resource "pgvecto-rs-cloud_cluster" "enterprise_plan_cluster" {
      + account_id        = "58aa09d9-854c-4223-9920-b85b9d878e72"
      + cluster_name      = "enterprise-plan-cluster"
      + cluster_provider  = "aws"
      + connect_endpoint  = (known after apply)
      + database_name     = "test"
      + enable_pooler     = true
      + id                = (known after apply)
      + last_updated      = (known after apply)
      + pg_data_disk_size = "10"
      + plan              = "Starter"
      + region            = "us-east-1"
      + server_resource   = "aws-t3-xlarge-4c-16g"
      + status            = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + psql_endpoint_enterprise = (known after apply)

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Creating...
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [10s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [20s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [30s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [40s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [50s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [1m0s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [1m10s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [1m20s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [1m30s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [1m40s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [1m50s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [2m0s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Still creating... [2m10s elapsed]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Creation complete after 2m18s [id=ee6d7049-78c4-4776-8e2a-25dd5970007d]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

psql_endpoint_enterprise = "postgres://test_user:fC5PHTFaflwK@enterprise-plan-cluster-jeyahasdf1r957q4.pooler.us-east-1.aws.pgvecto.rs:5432/test?sslmode=require"

你可以使用 psql 通过输出的连接字符串连接到数据库。随后可以执行 \dx 查看已安装的扩展,并创建一个 hnsw 索引来验证 vectors 扩展是否已成功安装。

$ psql 'postgres://test_user:fC5PHTFaflwK@enterprise-plan-cluster-unn3o7zyzdkrrxpe.us-east-1.aws.pgvecto.rs:5432/test?sslmode=require'
psql (15.3, server 16.3 (Debian 16.3-1.pgdg110+1))
WARNING: psql major version 15, server major version 16.
         Some psql features might not work.
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.

test=> \dx
                                                 List of installed extensions
  Name   | Version |   Schema   |                                         Description
---------+---------+------------+----------------------------------------------------------------------------------------------
 pgaudit | 16.0    | public     | provides auditing functionality
 plpgsql | 1.0     | pg_catalog | PL/pgSQL procedural language
 vectors | 0.3.0   | vectors    | vectors: Vector database plugin for Postgres, written in Rust, specifically designed for LLM
(3 rows)

test=> CREATE TABLE test (id integer PRIMARY KEY, embedding vector(3) NOT NULL);
CREATE TABLE
test=> INSERT INTO test SELECT i, ARRAY[random(), random(), random()]::real[] FROM generate_series(1, 100) i;
INSERT 0 100
test=> CREATE INDEX ON test USING vectors (embedding vector_l2_ops) WITH (options = "[indexing.hnsw]");
CREATE INDEX
test=> SELECT * FROM test ORDER BY embedding <-> '[0.40671515, 0.24202824, 0.37059402]' LIMIT 10;
 id |              embedding
----+--------------------------------------
 58 | [0.37351534, 0.13275076, 0.43973163]
 66 | [0.22059213, 0.27068633, 0.22881117]
 10 | [0.26735276, 0.39847502, 0.20896937]
 72 | [0.23406255, 0.4242782, 0.27623454]
 65 | [0.4541286, 0.48433396, 0.48011246]
 80 | [0.6192282, 0.14963196, 0.5095764]
 47 | [0.48413, 0.46047017, 0.21654463]
 97 | [0.445769, 0.27853656, 0.6808209]
 25 | [0.1864713, 0.015588521, 0.3540618]
 63 | [0.26299608, 0.0640332, 0.5937908]
(10 rows)

升级集群

目前我们支持对服务器规格(server_resource)、套餐类型(plan)以及磁盘容量(pg_data_disk_size)进行升级。升级操作存在以下约束:

  • 套餐升级必须是从 Starter 升级到 Enterprise。

  • 新的磁盘大小必须大于或等于原有大小。

  • 如果要在 Enterprise 套餐中升级服务器规格,新服务器规格的 CPU 和内存必须大于或等于原有规格。

+ server_resource   = "aws-r7i-large-2c-16g"
- server_resource   = "aws-m7i-large-2c-8g"
+ pg_data_disk_size = "20"
- pg_data_disk_size = "10"
+ plan = "Enterprise"
- plan = "Starter"
$ terraform apply

销毁集群

注意:一旦删除集群将无法恢复,请务必谨慎操作。

如果你需要删除 Postgres 集群,可以使用以下命令:

$ terraform destroy
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Refreshing state... [id=ee6d7049-78c4-4776-8e2a-25dd5970007d]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # pgvecto-rs-cloud_cluster.enterprise_plan_cluster will be destroyed
  - resource "pgvecto-rs-cloud_cluster" "enterprise_plan_cluster" {
      - account_id        = "58aa09d9-854c-4223-9920-b85b9d878e72" -> null
      - cluster_name      = "enterprise-plan-cluster" -> null
      - cluster_provider  = "aws" -> null
      - connect_endpoint  = "postgres://test_user:fC5PHTFaflwK@enterprise-plan-cluster-jeyahasdf1r957q4.pooler.us-east-1.aws.pgvecto.rs:5432/test?sslmode=require" -> null
      - database_name     = "test" -> null
      - enable_pooler     = true -> null
      - id                = "ee6d7049-78c4-4776-8e2a-25dd5970007d" -> null
      - last_updated      = "Tuesday, 23-Jul-24 00:52:55 UTC" -> null
      - pg_data_disk_size = "10" -> null
      - plan              = "Starter" -> null
      - region            = "us-east-1" -> null
      - server_resource   = "aws-t3-xlarge-4c-16g" -> null
      - status            = "Ready" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Changes to Outputs:
  - psql_endpoint_enterprise = "postgres://test_user:fC5PHTFaflwK@enterprise-plan-cluster-jeyahasdf1r957q4.pooler.us-east-1.aws.pgvecto.rs:5432/test?sslmode=require" -> null

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Destroying... [id=ee6d7049-78c4-4776-8e2a-25dd5970007d]
pgvecto-rs-cloud_cluster.enterprise_plan_cluster: Destruction complete after 1s

Destroy complete! Resources: 1 destroyed.

总结

将 Terraform 集成到 PGVecto.rs Cloud 中,为希望在 Postgres 上利用先进向量搜索能力的企业和开发者提供了一套强大的解决方案。这种自动化的基础设施即代码(IaC)方法不仅简化了部署流程,还增强了应用的稳健性与可扩展性。如果你有任何疑问或在 Terraform Provider 中发现了安全问题,欢迎联系我们,我们将非常乐意为你提供帮助。

24 - 释放稀疏向量的强大潜能

结合稀疏向量、BGE-M3 与 pgvecto.rs,以纯向量搜索释放数据流水线的全部潜能

在过去,混合搜索(hybrid search)通常结合两种搜索方式:传统的基于关键词的搜索与基于向量的相似度搜索。然而,稀疏向量(sparse vector)可以作为关键词搜索的替代方案,通过纯向量搜索释放数据流水线的全部潜能。

本文将探讨如何结合 BGE-M3 与 pgvecto.rs,同时利用稀疏向量和稠密向量(dense vector)来充分发挥混合搜索的潜力。

让我们开始吧。

稀疏向量与稠密向量嵌入概览

Sparse Vector VS Dense Vector

稀疏向量是仅包含少量非零值的高维向量,非常适合传统的信息检索场景。例如,一个拥有 32,000 个维度但只有 2 个非零元素的向量,就是典型的稀疏向量。

S[1×32000]=[000.0150000⋯0.5430] S[1\times 32000]=\left[ \begin{matrix} 0 & 0 & 0.015 & 0 & 0 & 0 & 0 & \cdots & 0.543 & 0 \end{matrix} \right]

稠密向量则是来自神经网络的嵌入表示。它们由文本嵌入模型生成,其中绝大部分或所有元素均为非零值。它们的维度要少得多,通常为 256 或 1536 维,远低于稀疏向量。

D[1×256]=[0.3421.7740.8700.001⋯0.5430.999] D[1\times 256]=\left[ \begin{matrix} 0.342 & 1.774 & 0.870 & 0.001 & \cdots & 0.543 & 0.999 \end{matrix} \right]

稠密向量和稀疏向量都用于表示文本片段的特征,但二者存在显著差异。稠密向量嵌入中的单个维度本身并没有明确含义,因为它过于抽象,无法直接确定其具体所指;只有将所有维度组合在一起时,它们才能共同表达输入文本的语义信息。

稀疏向量的维度等于词表大小(vocabulary size),其中每个元素代表对应词语在文本中的权重。

通常情况下,权重越高的词在文本中越重要。稀疏向量简化了对文本与词表之间关系的分析和可视化。而若要对稠密向量进行可视化,则需要借助额外的降维算法,例如:

稠密向量 (Dense Vector) 稀疏向量 (Sparse Vector)
维度 数百至数千 数万
元素含义 无独立含义 词语权重
可解释性 难 易
用途 向量距离计算 向量距离计算 / 可视化
语义敏感度 高 低

与稠密向量相比,稀疏向量在捕捉词语间关系(尤其是同义词和相关术语)方面通常表现较弱。

例如,rocket(火箭)和 SPACE X 都与太空探索相关,稠密向量更有可能发现它们之间的相似性,并为相应文本赋予更近的向量距离。

如何生成向量嵌入?

向量嵌入是输入数据在深度学习模型(即嵌入模型)中的内部表示。大多数嵌入模型(如 text-embedding-3-small)仅输出稠密嵌入。

SPLADE 和 BGE-M3 则可以生成稀疏嵌入(有时也称为词法权重 lexical weights)。在本节中,我们将介绍如何使用 BGE-M3 模型来同时生成稠密向量和稀疏向量。

关于 BGE-M3

BGE-M3 是一个多功能模型,能够同时执行嵌入模型的三种常见检索功能:稠密检索(dense retrieval)、多向量检索(multi-vector retrieval)和稀疏检索(sparse retrieval)。

# pip install -U FlagEmbedding

from FlagEmbedding import BGEM3FlagModel

model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)
sentences = ["What is BM25?", "Definition of BM25"]

output = model.encode(sentences, return_dense=True, return_sparse=True)

# The dense embedding of the two texts above:
dense_embedding = output['dense_vecs']
# [[-0.02501617 -0.04525582 -0.01972856 ... -0.0099566   0.01677509
#   0.01292699]
# [-0.0106499  -0.04731942 -0.01477693 ... -0.0149255   0.01040097
#   0.00965083]

sparse_weight_id = output['lexical_weights']

# You can see the weight for each token:
# print(model.convert_id_to_token(sparse_weight_id))
# 
# [{'What': 0.10430284, 'is': 0.10090457, 'BM': 0.2635918, '25': 0.3382988, '?': 0.052101523}, 
# {'Definition': 0.14298248, 'of': 0.076763615, 'BM': 0.2577639, '25': 0.33806682}]

通过将文本输入到像 BGE-M3 这样的稀疏嵌入模型中,我们可以轻松获得稀疏嵌入。

# pip install -U numpy

import numpy as np

# Sparse vector embedding of text
sparse_indices = [[int(token_id) for token_id in text] for text in sparse_weight_id]
# [[4865, 83, 90017, 2588, 32], [155455, 111, 90017, 2588]]
sparse_values = [[float(text[token_id]) for token_id in text] for text in sparse_weight_id]
# [[0.10430284, 0.10090457, 0.2635918, 0.3382988, 0.052101523], [0.14298248, 0.076763615, 0.2577639, 0.33806682]]

# Sparse indexes must be ascending for each pgvecto.rs
for i in range(len(sparse_weight_id)):
    sparse_values[i] = [x for _, x in sorted(zip(sparse_indices[i], sparse_values[i]))]
    sparse_indices[i] = sorted(sparse_indices[i])

现在我们已经同时获得了稀疏嵌入和稠密嵌入,接下来将它们插入向量数据库。

在 pgvecto.rs 中存储向量

首先,需要部署一个 pgvecto.rs 实例用于向量的存储与检索。可以通过官方的 pgvecto-rs Docker 镜像快速启动:

docker run \
  --name pgvecto-rs-demo \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -d tensorchord/pgvecto-rs:pg16-v0.3.0

安装好这一唯一必需的依赖项后,让我们创建一张表来存储向量。

pgvecto.rs 通过不同的数据类型支持稠密向量和稀疏向量:

  • vector:用于稠密向量

  • svector:用于稀疏向量

BGE-M3 模型的稠密嵌入维度为 1024,稀疏嵌入维度为 250002(与分词器词表大小一致)。

print(len(model.tokenizer))
# 250002

在本例中,我们创建一张名为 documents 的表,包含四列:ID 列(id)、文本列(text)以及两个向量列(sparse 用于存储稀疏向量,dense 用于存储稠密向量)。

# pip install -U psycopg

import psycopg
URL = "postgresql://postgres:mysecretpassword@localhost:5432/postgres"

with psycopg.connect(URL) as conn:
    conn.execute("DROP TABLE IF EXISTS documents;")
    conn.execute(f"""
        CREATE TABLE documents (
            id SERIAL PRIMARY KEY, 
            text TEXT NOT NULL,
            dense vector(1024) NOT NULL,
            sparse svector({len(model.tokenizer)}) NOT NULL);
    """
    )

表创建完成后,我们就可以将嵌入插入表中,并为向量列创建索引。

# pip install -U pgvecto_rs

import psycopg
from pgvecto_rs.psycopg import register_vector
URL = "postgresql://postgres:mysecretpassword@localhost:5432/postgres"

with psycopg.connect(URL) as conn:
    register_vector(conn)
    for text, dense, sparse_ind, sparse_val in zip(sentences, dense_embedding, sparse_indices, sparse_values):
        conn.execute(
            "INSERT INTO documents (text, dense, sparse) VALUES (%s, %s, to_svector(%s, %s::real[]::int[], %s::real[]));",
            (text, dense, len(model.tokenizer), sparse_ind, sparse_val),
        )
    conn.execute("""
        CREATE INDEX ON documents 
        USING vectors (sparse svector_dot_ops) 
        WITH (options = \"[indexing.hnsw]\");
    """)
    conn.execute("""
        CREATE INDEX ON documents 
        USING vectors (dense vector_l2_ops) 
        WITH (options = \"[indexing.hnsw]\");
    """)

对于稠密向量,vector_l2_ops 计算的是平方欧氏距离(Squared Euclidean distance),这是最常用的距离度量方式。

DL2=Σ(xi−yi)2 D_{L2} = \Sigma (x_i - y_i) ^ 2

对于稀疏向量,svector_dot_ops 计算的是点积(dot product),这是一种更加高效的计算方法。

Ddot=−Σxiyi D_{dot} = - \Sigma x_iy_i

有关 pgvecto.rs 中索引类型的更多信息,请查阅我们的文档。

检索与混合结果

将向量存入数据库后,我们便可以使用 pgvecto.rs 扩展高效地查询它们。为了找出与给定输入文本最接近的向量,我们需要使用相同的模型对该文本进行编码。

这一过程与上一节介绍的步骤完全相同:

def embedding(sentence: str) -> tuple[np.ndarray, list[list[int]], list[list[float]]]:
    """
    Create dense and sparse embeddings from text
    """
    output = model.encode(sentence, return_dense=True, return_sparse=True, return_colbert_vecs=False)
    dense_embedding = output['dense_vecs']
    sparse_weight_id = output['lexical_weights']
    sparse_indices = [int(token_id) for token_id in sparse_weight_id]
    sparse_values = [float(sparse_weight_id[token_id]) for token_id in sparse_weight_id]

    sparse_values = [x for _, x in sorted(zip(sparse_indices, sparse_values))]
    sparse_indices = sorted(sparse_indices)
    return dense_embedding, sparse_indices, sparse_values

pgvecto.rs 扩展提供了简洁的 SELECT 语句来分别检索稀疏向量和稠密向量,如下所示:

dense_embedding, sparse_indices, sparse_values = embedding("The text you want to search...")

with psycopg.connect(URL) as conn:
    register_vector(conn)
    cur = conn.execute(
        "SELECT text FROM documents ORDER BY sparse <#> to_svector(%s, %s::real[]::int[], %s::real[]) LIMIT 50;",
        (len(model.tokenizer), sparse_indices, sparse_values),
    )
    sparse_result = cur.fetchall()
      
    cur = conn.execute(
              "SELECT text FROM documents ORDER BY dense <-> %s LIMIT 50;",
              (dense_embedding,),
          )
    dense_result = cur.fetchall()
    
# Merge sparse and dense vector search results
mix_text = set([r[0] for r in dense_result]).union([r[0] for r in sparse_result])

为了对合并后的结果进行重排序,您可以使用常见的融合方法,例如倒数排序融合(Reciprocal Rank Fusion, RRF)。不过,为了更好地发挥 BGE-M3 的优势,我们可以引入 BGE-M3 配套的 Reranker(重排序)模型。与嵌入模型不同,Reranker 直接接收文本对作为输入,并输出它们之间的相似度或相关性得分。

我们可以初始化一个 Reranker 模型,并将混合后的候选文本输入其中。通过这种方法,重排序就像按照 Reranker 模型评估的得分进行排序一样简单。

from FlagEmbedding import FlagReranker

reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)

# Obtain relevance scores (Higher scores indicate greater relevance)
scores = reranker.compute_score([["The text you want to search...", candidate] for candidate in mix_text])
# Rerank text using combined dense and sparse embeddings
reranked_text = [t for _, t in sorted(zip(scores, mix_text))]

恭喜!现在我们已经迈出了探索稀疏向量搜索世界的第一步。

总结

在本文中,我们使用 BGE-M3 模型生成了稀疏向量和稠密向量,并在 Postgres 数据库中完成了存储与查询。这一切都得益于 pgvecto.rs v0.3.0 引入的全新特性:稀疏向量类型。

敬请期待,我们将继续分享更多关于 pgvecto.rs 的精彩能力!

欢迎加入我们的 Discord 社区,与开发者及其他用户交流互动,共同推动 pgvecto.rs 的持续进化!

25 - pg_bestmatch.rs:用 BM25 全面提升 PostgreSQL 文本查询能力

Postgres BM25 全文检索、更出色的 RAG 与混合搜索
提示

更新提示:欢迎了解我们最新的扩展 VectorChord-BM25,它在 PostgreSQL 中实现了端到端的 BM25 排序。详情请阅读:https://vchord.pgsty.com/zh/blog/post/vectorchord-bm25-revolutionize-postgresql-search-with-bm25-ranking-3x-faster-than-elasticsearch/

%[https://github.com/tensorchord/VectorChord-bm25]

我们非常高兴地宣布发布 pg_bestmatch.rs——一款将 Best Matching 25(BM25)文本查询能力引入 PostgreSQL 数据库的扩展,助你执行更高效、更精准的文本检索。借助该扩展,用户可以根据文本生成 BM25 统计稀疏向量,在数据库中直接发挥 BM25 在各类基准测试任务中久经验证的卓越表现。

为什么选择 BM25?

BM25(Best Matching 25)是信息检索中广泛使用的一种概率排序函数,用于评估文档与查询之间的匹配程度。它基于词频(TF)和逆文档频率(IDF)计算相关性得分,并通过文档长度归一化加以平衡。该公式确保在文档内部高频出现的词项(TF)以及在整个语料库中罕见的词项(IDF)获得恰当的权重,从而显著提升搜索准确度与相关性。

在众多 RAG 基准测试任务中,BM25 的表现已被证明优于单纯基于稠密向量的检索方法。通过将 BM25 集成到 PostgreSQL 中,你可以获得更出色的搜索性能与相关性,尤其适合对文本检索质量要求较高的应用场景。同时,你还可以将它与向量搜索结合,构建强大的混合搜索(Hybrid Search)方案。

核心特性

  • BM25 统计稀疏向量:基于你自己的文本数据,为文本生成 BM25 稀疏向量表征。

  • 与向量搜索扩展无缝集成:兼容 pgvecto.rs 和 pgvector,在 PostgreSQL 中实现高效的向量检索。

  • 开箱即用的分词能力:采用 Huggingface 的 bert-base-uncased 词表与字节对编码(BPE)分词器,通过子词(subword)切分显著提升检索效果。

工作原理

BM25 score(query,document)=∑t∈qIDF(t)⏟query⋅f(t,d)⋅(k1+1)f(t,d)+k1⋅(1−b+b⋅∣d∣avgdl)⏟document=∑t∈qqt⋅dt=q⃗⋅d⃗ \begin{aligned} \text{BM25 score}(\text{query}, \text{document}) &= \sum_{t \in q} \underbrace{\text{IDF}(t)}{\text{query}} \cdot \underbrace{\frac{f(t, d) \cdot (k_1 + 1)}{f(t, d) + k_1 \cdot (1 - b + b \cdot \frac{|d|}{\text{avgdl}})}}{\text{document}} \\ &= \sum_{t \in q} q_t \cdot d_t = \vec{q} \cdot \vec{d} \end{aligned}

BM25 的计算过程可以分解为查询(query)和文档(document)两个独立的部分。最终的相关性得分等价于查询稀疏向量与文档稀疏向量之间的点积(dot product)。因此,我们巧妙地将 BM25 排序转化为基于点积距离的稀疏向量检索问题!

使用方法

下面是使用该扩展的分步指南:

  1. 创建 BM25 统计信息:使用 bm25_create 函数为你的文档集合生成 BM25 统计信息。该函数接收表名、列名和统计名称作为参数,并创建一个物化视图来存储 BM25 统计数据。

    SELECT bm25_create('documents', 'passage', 'documents_passage_bm25');
  2. 生成文档稀疏向量:使用 bm25_document_to_svector 函数将文档文本转换为稀疏向量表征。该函数接收 BM25 统计名称和文档文本作为输入。

    SELECT bm25_document_to_svector('documents_passage_bm25', 'document_text') as document_vector;
  3. 生成查询稀疏向量:对于每次搜索查询,使用 bm25_query_to_svector 函数生成对应的查询稀疏向量。该函数同样接收 BM25 统计名称和查询文本作为输入。

    SELECT bm25_query_to_svector('documents_passage_bm25', 'search_query') as query_vector;
  4. 计算相关性得分:通过计算查询稀疏向量与文档稀疏向量之间的负点积(<#>),得出文档对于给定查询的相关性得分。该得分反映了文档与查询的匹配程度。

    SELECT document_id, (query_vector <#> document_vector) AS bm25_score
    FROM documents
    ORDER BY bm25_score
    LIMIT 10;

加入社区

我们诚邀你试用 pg_bestmatch.rs 并分享你的反馈。在我们持续改进和打磨这一扩展的过程中,你的贡献与建议至关重要。欢迎为我们的 GitHub 仓库点亮 Star,并加入我们的 Discord 服务器,成为我们不断壮大的社区中的一员!

立即使用 pg_bestmatch.rs 体验 PostgreSQL 中 BM25 文本查询的强大威力——你高效、精准文本检索的全新得力工具。

26 - 我的二值向量搜索比你的 FP32 向量更强

二值向量搜索:在保持精度的同时实现 30 倍内存缩减的革命

在向量搜索领域,一项引人注目的技术进展正在兴起:二值向量搜索(binary vector search)。这种方法有望通过实现惊人的 30 倍内存缩减,攻克长期存在的内存消耗难题。然而,一个引发广泛讨论的关键问题是:它会对检索精度产生怎样的影响?

我们认为,将二值向量搜索与特定的优化技术相结合,完全可以保持相近的检索精度。为了阐明这一点,我们将在本文中展示一系列实验,演示该方法的实际效果及其重要意义。

什么是二值向量?

二值向量(binary vector)是一种将向量中的每个元素编码为二值(通常为 0 或 1)的向量表征形式。这种编码方案将原本包含实数值或高维浮点数据的原始向量转换为二值格式。

V[1×256]=[−0.0210.2430.065−0.223⋯0.452−0.248] V[1\times 256]=\left[ \begin{matrix} -0.021 & 0.243 & 0.065 & -0.223 & \cdots & 0.452 & -0.248 \end{matrix} \right] BV[1×256]=[0110⋯10] BV[1\times 256]=\left[ \begin{matrix} 0 & 1 & 1 & 0 & \cdots & 1 & 0 \end{matrix} \right]

二值向量存储每个元素仅需 1 比特(bit)内存,而原始的 float32 向量存储每个元素则需要 4 字节(32 bit)。这意味着使用二值向量可以将内存占用减少高达 32 倍。此外,内存需求的下降还伴随着二值向量运算每秒请求数(RPS)的显著提升。

不妨看一个例子:假设我们有 100 万个向量,每个向量由 3072 维空间中的 float32 值表示。在这种情况下,原始的 float32 向量索引大约需要 20 GB 内存来存储这 100 万个向量。

现在,如果我们改用二值向量,内存占用将大幅下降:二值向量索引大约只需 600 MB 即可存储全部 100 万个向量。

不过,人们通常预计这种内存缩减会导致精度大幅下降,因为二值向量丢失了大量原始信息。

出乎意料的是,我们的实验表明,精度的下降幅度远没有预想的那么大。尽管二值向量丢失了一些具体细节,但它们依然能够捕捉到关键的模式与相似性特征,从而维持相当可观的准确率。

实验评估

为了评估其与原始向量方法相比的性能指标,我们使用 dbpedia-entities-openai3-text-embedding-3-large-3072-1M 数据集进行了基准测试。测试运行在 Google Cloud 的 n2-standard-8 虚拟机(VM)上(配备 8 个 vCPU 和 32GB 内存),并使用 pgvecto.rs v0.2.1 作为向量数据库。

向数据库表中插入 100 万个向量后,我们分别为原始 float32 向量和二值向量构建了索引:

CREATE TABLE openai3072 (
  id bigserial PRIMARY KEY,
  text_embedding_3_large_3072_embedding vector(3072),
  text_embedding_3_large_3072_bvector bvector(3072)
);

CREATE INDEX openai_vector_index on openai3072 using vectors(text_embedding_3_large_3072_embedding vector_l2_ops);

CREATE INDEX openai_vector_index_bvector ON public.openai3072 USING vectors (text_embedding_3_large_3072_bvector bvector_l2_ops);

索引构建完成后,我们执行了向量搜索查询以评估性能。这些查询设置了不同的返回数量上限(limit 5、10、50、100)。

我们观察到,二值向量搜索的每秒请求数(RPS)约为 3000,而原始向量搜索的 RPS 仅为 300 左右。

RPS 指标代表系统每秒能够处理的请求或查询数量。更高的 RPS 值意味着更高的吞吐量和更快的响应速度。

不过,与原始向量搜索相比,二值向量搜索的准确率下降到了约 80%。虽然在某些场景下这种幅度的下降尚可接受,但在对高精度有严格要求的业务场景中,80% 的准确率显然是不够的。

优化:自适应检索(Adaptive Retrieval)

幸运的是,我们从 Matryoshka 表征学习(MRL)中借鉴了一种简单而有效的方法——自适应检索(adaptive retrieval),用以提升准确率。

名字听起来有些复杂,但自适应检索背后的核心思想非常直观。假设我们想要找出最佳的 100 个候选结果,可以分两步走:

  1. 查询二值向量索引,从 100 万个嵌入向量中快速召回一个更大的候选集(例如 200 个候选结果)。这一步操作极快。

  2. 使用全精度 KNN 查询对候选集进行重排序(rerank),从中选出最终的 Top 100 结果。请注意,这里我们运行的是精确的 KNN 而非近似的 ANN。KNN 非常适合在小规模集合上进行精确的相似度计算,因此是此处重排序的理想选择。

通过引入这一重排序步骤,我们可以实现准确率的显著跃升——最高可达 95%!与此同时,系统依然保持着约 1700 的高 RPS。更重要的是,尽管精度得到了大幅提升,索引的内存占用依然比原始向量表征小约 30 倍。

以下是用于执行自适应检索的 SQL 代码:

CREATE OR REPLACE FUNCTION match_documents_adaptive(
  query_embedding vector(3072),
  match_count int
)
RETURNS SETOF openai3072
LANGUAGE SQL
AS $$
-- Step 1: Query binary vector index to retrieve match_count * 2 candidates
WITH shortlist AS (
  SELECT *
  FROM openai3072
  ORDER BY text_embedding_3_large_3072_bvector <-> binarize(query_embedding)
  LIMIT match_count * 2
)
-- Step 2: Rerank the candidates using a KNN query to retrieve the top candidates
SELECT *
FROM shortlist
ORDER BY text_embedding_3_large_3072_embedding <-> query_embedding
LIMIT match_count;
$$;

与截断降维向量(Shortening Vectors)的对比

OpenAI 最新的嵌入模型 text-embedding-3-large 提供了一项允许用户截断缩短向量(shorten vectors)的功能。

该模型默认生成 3072 维的嵌入向量,但你可以安全地移除序列末尾的一部分数值,同时仍然保留对文本的有效表征。例如,你可以将嵌入向量截断缩短至 1024 维。

这项功能同样可以帮助你节省内存并加快请求处理速度,效果与二值向量类似。因此,将两者的性能进行对比,看看哪一种更适合你的需求是一个很好的主意。

基于我们的测试结果,结论非常明确:二值向量的表现显著优于截断缩短后的向量。

我们执行了类似的基准测试以与二值向量进行对比:使用相同的数据集和机器配置创建了两个不同维度的索引,一个为 256 维,另一个为 1024 维。

1024 维索引在 1000 RPS 的吞吐量下达到了约 85% 的准确率;而 256 维索引虽然达到了 1200 RPS 的更高吞吐量,但准确率仅有 60% 左右。

在内存占用方面,1024 维索引大约需要 8GB 内存,256 维索引大约需要 2GB 内存。相比之下,二值向量方法仅用约 600MB 内存,就在 3000 RPS 的吞吐量下达到了约 80% 的准确率。

随后,我们又在低维索引上同样应用了自适应检索。结果显示,无论是在请求吞吐量(RPS)还是准确率上,二值向量索引依然全面优于 256 维索引,同时内存占用也更低。另一方面,结合自适应检索的 1024 维索引虽然达到了 99% 的更高准确率,但它的请求吞吐量相对较低,且消耗的内存是二值向量索引的 12 倍以上。

总结

通过采用自适应检索技术,二值向量能够在保持高水平准确率的同时,将内存占用大幅降低 30 倍。我们在下表中汇总了各项基准测试指标以直观展示结果。需要注意的是,上述实验结论针对的是具备这一特定表征性质的 OpenAI text-embedding-3-large 模型。

27 - 2024 年 pgvector vs. pgvecto.rs:PostgreSQL 向量搜索全方位对比

在这篇博客文章中,我们将全面剖析 pgvector 与 pgvecto.rs 之间的差异。

pgvector 和 pgvecto.rs 都是旨在增强 PostgreSQL 向量检索能力的扩展。这些扩展彻底改变了数据库内部执行向量搜索的方式,提供了可扩展、低延迟且支持混合检索的解决方案。在这篇博客文章中,我们将详细阐述 pgvector 与 pgvecto.rs 之间的核心差异。

省流版(TL; DR):请直接跳转到文章末尾的总结小节,其中的对比表格汇总了两者之间的主要区别。

搜索是数据库的核心功能,在高效数据检索中扮演着至关重要的角色,能够帮助用户快速定位并访问所需信息。下面我们将展示两者的性能表现,并重点介绍 pgvecto.rs 和 pgvector 各自提供的功能特性。

过滤检索(Filtering)

虽然 pgvector 和 pgvecto.rs 都支持在 PostgreSQL 中进行向量相似度搜索,但 pgvecto.rs 更进一步,引入了来自 OSDI 2023 的 VBASE 方法。

真实业务负载往往涉及向量搜索与关系型查询的结合。向量搜索擅长相似度匹配与相似向量召回,而关系型查询则允许你在结构化数据上执行复杂的连接(JOIN)、过滤(Filter)和聚合操作。

pgvecto.rs 专为高效应对**单向量 TopK + 过滤 + 连接(Single-Vector TopK + Filter + Join)**场景而设计。

鉴于目前缺乏通用的关系型向量基准测试集,我们采用 laion-768-5m-ip-probability 数据集进行基准测试。该数据集衍生自 LAION 2B 图像数据集,包含 5,000,000 个向量和 10,000 条查询。

该数据集包含一个 probability 列,存储从 0 到 1 均匀分布中生成的随机浮点数。过滤比例为 0.01 意味着每条查询覆盖数据集的 1%(即 0.01 倍),便于进行针对性分析。

我们在保持 ef_search 恒定的前提下,展示了不同 probability 过滤范围下的召回率(recall)、延迟(毫秒)和每秒请求数(RPS)。ef_search 参数代表 k-NN(k 近邻)搜索期间维护的候选列表大小,决定了搜索精度与查询耗时之间的权衡。

结合 VBASE 的 pgvecto.rs 始终能够带来更高的召回率,尤其是在过滤选择率较低(low probability)的场景下优势尤为明显。

稀疏向量(sparse vector)是仅包含极少数非零元素的高维向量,非常适合传统的信息检索场景。例如,一个拥有 32,000 个维度但只有 2 个非零元素的向量,就是典型的稀疏向量。

S[1×32000]=[000.0150000⋯0.5430] S[1\times 32000]=\left[ \begin{matrix} 0 & 0 & 0.015 & 0 & 0 & 0 & 0 & \cdots & 0.543 & 0 \end{matrix} \right]

稠密向量(dense vector)则是来自神经网络的嵌入向量,通常由文本嵌入模型生成,其中绝大多数乃至所有元素均为非零值。它们的维度相对较少,例如 256 或 1536 维,远低于稀疏向量。

D[1×256]=[0.3421.7740.0870.8700.001⋯0.5430.999] D[1\times 256]=\left[ \begin{matrix} 0.342 & 1.774 & 0.087 & 0.870 & 0.001 & \cdots & 0.543 & 0.999 \end{matrix} \right]

pgvector 对稠密向量搜索支持良好,但并无支持稀疏向量的计划(注:撰写本文时)。

相比之下,pgvecto.rs 同时支持稠密向量搜索和稀疏向量搜索。它提供了 svector 数据类型,用于构建稀疏向量索引并执行高效检索。

CREATE TABLE items (
  id bigserial PRIMARY KEY,
  embedding svector(10) NOT NULL
);

INSERT INTO items (embedding) VALUES ('[0.1,0,0,0,0,0,0,0,0,0]');

CREATE INDEX your_index_name ON items USING vectors (embedding svector_l2_ops);

SELECT * FROM items ORDER BY embedding <-> '[0.3,0,0,0,0,0,0,0,0,0]' LIMIT 1;

向量维度上限

pgvecto.rs 扩展支持最高 65535 维的向量,而 pgvector 扩展最高仅支持 2000 维。这一维度支持上的差异使得 pgvecto.rs 能够直接处理来自 OpenAI 最新嵌入模型的高维向量。

单指令多数据流(SIMD)

pgvecto.rs 会在运行时动态分派并充分利用用户机器上可用的 SIMD(单指令多数据流)指令集;而 pgvector 依赖编译器在编译期生成 SIMD 代码,无法在运行时自动选择更快的指令集。得益于更快的向量距离计算速度,pgvecto.rs 中的搜索性能更加出色。

数据类型(Data Types)

二值向量(Binary Vector)

bvector 是 pgvecto.rs 中的二值向量类型。它表示每个分量只能取两个可能值(通常为 0 和 1)的二值向量。

CREATE TABLE items (
  id bigserial PRIMARY KEY,
  embedding bvector(3) NOT NULL
);

INSERT INTO items (embedding) VALUES ('[1,0,1]'), ('[0,1,0]');

CREATE INDEX your_index_name ON items USING vectors (embedding bvector_l2_ops);

SELECT * FROM items ORDER BY embedding <-> '[1,0,1]' LIMIT 5;

在特定嵌入模型(如 OpenAI 嵌入模型)中,二值向量具有显著优势:它能够在大幅降低内存占用的同时,保持令人满意的检索精度。

以下是 bvector 类型的性能基准测试结果。我们使用 dbpedia-entities-openai3-text-embedding-3-large-3072-1M 数据集,在 Google Cloud 的 n2-standard-8(8 vCPU,32 GB 内存)虚拟机上进行测试。

我们将 100 万个二值向量写入表中,随后对每个嵌入向量运行 KNN 查询。为 100 万个二值向量构建索引仅需约 600MB 内存,而使用标准 vector 类型为相同数量的向量构建索引则需要约 18GB 内存。如果我们采用自适应检索(adaptive retrieval),bvector 的准确率可以超过 95%。

FP16/INT8

除了二值向量外,pgvecto.rs 还支持 FP16(16 位浮点数)和 INT8(8 位整数)向量数据类型。

索引架构(Indexing)

与 pgvector 相比,pgvecto.rs 采用了截然不同的索引架构。它独立于 PostgreSQL 原生存储引擎来管理索引的存储与内存,而不是像 pgvector 那样依赖 PostgreSQL 的内置页面存储引擎。

这是一种架构上的权衡。在开发初期,我们也曾像 pgvector 一样,尝试使用 PostgreSQL 的页面存储(page storage)来实现 HNSW(Hierarchical Navigable Small World)索引。然而,我们遇到了诸多限制,阻碍了其性能与功能的发挥:

  • 并行化挑战:PostgreSQL 采用“每条语句对应一个进程”的进程模型,且缺乏线程安全的内部 API,这给并行化带来了很大挑战。在构建向量索引这类计算密集型任务中,并行化可以显著提升性能。然而,当我们尝试并行化这一过程时,却频繁遭遇 Too many shared buffer locked(锁定的共享缓冲区过多)错误。

  • 预写式日志(WAL)写放大:插入一个 2KB 的向量往往会产生超过 20KB 的 WAL 日志,这就是 WAL 写放大问题。这一问题源于 HNSW 算法本身的特性——插入单个数据点需要修改多条边,而 PostgreSQL 会在 WAL 中逐一记录每一次页面修改,从而导致 WAL 体积急剧膨胀。

  • 锁竞争(Lock Contention):在遍历 HNSW 图进行读写时,必须对每个节点的边列表加锁,从而引发锁竞争。由于 HNSW 采用层次化结构,越往上层包含的节点越少,因此在索引高并发访问期间,上层节点极易成为锁竞争的瓶颈。当多个操作试图同时访问或修改同一个边列表时,就会导致延迟增加和并发度下降。

为此,pgvecto.rs 采用了类似于 FreshDiskANN 的设计,借鉴了日志结构合并树(LSM-tree)的思想。该架构由三个组件构成:写入段(writing segment)、增长段(growing segment)和封存段(sealed segment)。新插入的向量首先写入 writing segment;随后后台进程异步将其转换为不可变的 growing segment;最后,growing segment 会与 sealed segment 进行合并,类似于 LSM-tree 中的压缩(compaction)过程。这种设计带来了多项显著优势:

  • 非阻塞插入:索引结构的修改操作不会阻塞前台的数据插入流程。

  • 批量修改:将对 HNSW 图的修改分批聚合处理,显著提升了整体吞吐量。

  • 消除读写锁竞争:由于 sealed segment 是不可变的,读写锁竞争问题得到了根本性缓解。

当然,这种方法也存在一定局限性。一个显著的不足是索引无法开箱即用地支持 PostgreSQL 原生的预写式日志(WAL)。这意味着依赖 WAL 的时间点恢复(PITR)和物理复制(Physical Replication)等功能暂无法直接作用于索引本身。不过,PostgreSQL 生态非常强大,允许扩展通过自定义资源管理器(Custom WAL Resource Manager)定义自己的 WAL。虽然实现这一机制需要额外的工程投入,但通过它完全可以克服这一限制,为索引提供完整的 WAL 支持。

总结

下表汇总了 pgvecto.rs 与 pgvector 之间的主要区别:

特性 pgvecto.rs pgvector
过滤检索(Filtering) 引入 VBASE 方法,高效支持向量搜索与关系型查询的结合(如单向量 TopK + Filter + Join)。 应用过滤条件时,返回结果可能不完整。例如原本希望 LIMIT 10,加上过滤条件后可能最终只返回 5 条结果。
稀疏向量搜索 同时支持稠密向量和稀疏向量搜索。 仅支持稠密向量搜索。
向量维度 最高支持 65535 维。 最高支持 2000 维。
SIMD 优化 在运行时根据具体机器硬件能力动态分派 SIMD 指令,最大化性能。 依赖编译器在编译期生成的 SIMD 代码。
数据类型 额外引入二值向量(bvector)、FP16(16 位浮点数)和 INT8(8 位整数)数据类型。 -
索引架构 独立于 PostgreSQL 管理索引的存储与内存。 依赖 PostgreSQL 的原生存储引擎。
WAL 支持 已支持数据级的预写式日志(WAL),索引级 WAL 支持正在开发中。 对索引和数据均提供原生 WAL 支持。

28 - 使用 LlamaIndex 与 pgvecto.rs 构建 RAG 流水线

在本篇博客中,我们将展示如何使用 pgvecto.rs 和 LlamaIndex 构建一套完整的 RAG 流水线。

LlamaIndex 是一个简单、灵活的数据框架,用于将自定义数据源连接到大语言模型(LLM)。

pgvecto.rs 提供了 LlamaIndex 集成,允许你快速检索最相似的向量。在本篇博客中,我们将向你展示如何使用 pgvecto.rs 和 LlamaIndex 构建一套 RAG 流水线。

安装依赖

使用 LlamaIndex 集成需要安装以下依赖项:

pip install llama-index "pgvecto_rs[sdk]"

你可以在 Docker 容器中启动安装了 pgvecto.rs 扩展的 Postgres 实例:

docker run \
  --name pgvecto-rs-demo \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -d tensorchord/pgvecto-rs:pg16-v0.2.0

然后,你可以使用 psql 命令行工具连接到数据库。默认用户名为 postgres,默认密码为 mysecretpassword。

psql postgresql://postgres:mysecretpassword@localhost:5432/postgres

运行以下 SQL 以确保扩展已启用:

DROP EXTENSION IF EXISTS vectors;
CREATE EXTENSION vectors;

创建数据库并加载文档

首先,配置数据库连接并初始化客户端。我们将使用 Markdown 文件 pgvecto-rs/getting-started/overview.md 作为示例文档。

import logging
import os
import sys

logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))

from pgvecto_rs.sdk import PGVectoRs

URL = "postgresql+psycopg://{username}:{password}@{host}:{port}/{db_name}".format(
    port=os.getenv("DB_PORT", "5432"),
    host=os.getenv("DB_HOST", "localhost"),
    username=os.getenv("DB_USER", "postgres"),
    password=os.getenv("DB_PASS", "mysecretpassword"),
    db_name=os.getenv("DB_NAME", "postgres"),
)

client = PGVectoRs(
    db_url=URL,
    collection_name="example",
    dimension=1536,  # Using OpenAI’s text-embedding-ada-002
)

接着,设置 OpenAI API 密钥并将文档加载到数据库中:

import os

os.environ["OPENAI_API_KEY"] = "sk-..."

from llama_index import SimpleDirectoryReader, VectorStoreIndex
from llama_index.vector_stores import PGVectoRsStore

# load documents
documents = SimpleDirectoryReader("./src/getting-started/overview.md").load_data()

# initialize without metadata filter
from llama_index.storage.storage_context import StorageContext

vector_store = PGVectoRsStore(client=client)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex.from_documents(
    documents, storage_context=storage_context
)

查询索引

最后,我们可以检索最相似的文本片段并生成回答:

# set Logging to DEBUG for more detailed outputs
query_engine = index.as_query_engine()
response = query_engine.query("What did the author do growing up?")

29 - 使用 pgvecto.rs 在 LangChain 中构建大语言模型应用

利用 pgvecto.rs 的强大能力,为你在 LangChain 中构建的大语言模型(LLM)应用全面加速

LangChain 是一个用于开发由语言模型驱动的应用程序的框架。它使应用程序能够:

  • 具备上下文感知能力:将语言模型连接到各种上下文来源(提示词指令、少样本示例、作为回答依据的业务内容等)。

  • 进行推理:依靠语言模型进行推理(例如如何根据提供的上下文作答、应当采取什么行动等)。

pgvecto.rs 是 Postgres 中的一款向量搜索扩展。在本篇博客中,我们将向你展示如何使用 LangChain 和 pgvecto.rs 构建 LLM 应用程序。

安装依赖

使用 LangChain 集成需要安装以下依赖项:

pip install langchain langchain-openai pgvecto-rs

你可以在 Docker 容器中启动安装了 pgvecto.rs 扩展的 Postgres 实例:

docker run \
  --name pgvecto-rs-demo \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -d tensorchord/pgvecto-rs:pg16-v0.2.0

然后,你可以使用 psql 命令行工具连接到数据库。默认用户名为 postgres,默认密码为 mysecretpassword。

psql postgresql://postgres:mysecretpassword@localhost:5432/postgres

运行以下 SQL 以确保扩展已启用:

DROP EXTENSION IF EXISTS vectors;
CREATE EXTENSION vectors;

创建数据库并加载文档

下面我们将展示如何在 LangChain 中使用 pgvecto.rs 检索最相似的向量。

首先,你需要创建文本加载器(TextLoader)和文本切分器(CharacterTextSplitter),将文本切分为多个片段(Chunks)。这里我们以 Markdown 文件 pgvecto-rs/getting-started/overview.md 为例:

## Loading Environment Variables
from dotenv import load_dotenv

load_dotenv()

from langchain.docstore.document import Document
from langchain.text_splitter import CharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores.pgvecto_rs import PGVecto_rs
from langchain_openai import OpenAIEmbeddings


loader = TextLoader("./src/getting-started/overview.md")
documents = loader.load()
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
docs = text_splitter.split_documents(documents)

embeddings = OpenAIEmbeddings()

接着,我们创建 PGVecto_rs 实例并将文档加载到数据库中:

## PGVecto.rs needs the connection string to the database.
## We will load it from the environment variables.
import os

PORT = os.getenv("DB_PORT", 5432)
HOST = os.getenv("DB_HOST", "localhost")
USER = os.getenv("DB_USER", "postgres")
PASS = os.getenv("DB_PASS", "mysecretpassword")
DB_NAME = os.getenv("DB_NAME", "postgres")

# Run tests with shell:
URL = "postgresql+psycopg://{username}:{password}@{host}:{port}/{db_name}".format(
    port=PORT,
    host=HOST,
    username=USER,
    password=PASS,
    db_name=DB_NAME,
)

# The pgvectors Module will try to create a table with the name of the collection.
# So, make sure that the collection name is unique and the user has the permission to create a table.

COLLECTION_NAME = "state_of_the_union_test"

db = PGVecto_rs.from_documents(
    embedding=embeddings,
    documents=docs,
    collection_name=COLLECTION_NAME,
    db_url=URL,
)

查询索引

最后,我们可以在 LangChain 中检索最相似的文本片段:

query = "What is pgvecto.rs"
docs_with_score = db.similarity_search_with_score(query)

for doc, score in docs_with_score:
    print("-" * 80)
    print("Score: ", score)
    print(doc.page_content)
    print("-" * 80)

输出结果如下:

Created a chunk of size 1181, which is longer than the specified 1000
--------------------------------------------------------------------------------
Score:  0.25059962
# Overview

An introduction to the pgvecto.rs.

## What is pgvecto.rs

pgvecto.rs is a Postgres extension that provides vector similarity search functions. It is written in Rust and based on [pgrx](https://github.com/tcdi/pgrx). It is currently in the beta status, we invite you to try it out in production and provide us with feedback. Read more at [📝our launch blog](https://modelz.ai/blog/pgvecto-rs).

## Why use pgvecto.rs
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
Score:  0.29536954
- 💃 **Easy to use**: pgvecto.rs is a Postgres extension, which means that you can use it directly within your existing database. This makes it easy to integrate into your existing workflows and applications.
- 🔗 **Async indexing**: pgvecto.rs's index is asynchronously constructed by the background threads and does not block insertions and always ready for new queries.
- 🥅 **Filtering**: pgvecto.rs supports filtering. You can set conditions when searching or retrieving points. This is the missing feature of other postgres extensions.
- 🧮 **Quantization**: pgvecto.rs supports scalar quantization and product qutization up to 64x.
- 🦀 **Rewrite in Rust**: Rust's strict compile-time checks ensure memory safety, reducing the risk of bugs and security issues commonly associated with C extensions.

## Comparison with pgvector
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
Score:  0.35845917
More details at [📝`pgvecto.rs` vs. pgvector](/faqs/comparison-pgvector.md).

## Quick start

For new users, we recommend using the [Docker image](https://hub.docker.com/r/tensorchord/pgvecto-rs) to get started quickly.
...

从现有数据库初始化

在上面的示例中,我们从零创建了一个向量存储。但在很多时候,我们希望直接使用已有的向量存储。为此,我们可以直接对其进行初始化:

db = PGVecto_rs(
    embedding=embeddings,
    collection_name=COLLECTION_NAME,
    # OpenAI embedding has 1536 dimensions.
    dimension=1536,
    db_url=URL,
)

随后,我们可以向存储中添加新向量并执行查询:

db.add_documents([Document(page_content="foo")])
docs_with_score = db.similarity_search_with_score("foo")
print(docs_with_score[0])

输出结果为:

(Document(page_content='foo'), 0.0)

30 - 我们真的需要专门的向量数据库吗?

随着大语言模型(LLM)的普及,向量数据库也成为了热门话题。只需寥寥几行简单的 Python 代码,向量数据库就能充当 LLM 的外挂大脑……

随着大语言模型(LLM)的普及,向量数据库也成为了热门话题。只需寥寥几行简单的 Python 代码,向量数据库就能充当 LLM 廉价却高效的“外挂大脑”。但我们真的需要一个专门的向量数据库吗?

首先,让我简要介绍一下为什么 LLM 需要使用向量搜索技术。向量搜索是一个存在已久的问题:给定一个对象,在集合中找出与之最相似对象的过程就是向量搜索。文本、图像等都可以转换为向量表示,从而将文本或图像的相似性问题转化为向量相似性问题。

文本搜索(图片来源)

在上面的例子中,我们将不同的单词转换为三维向量,因此可以在三维空间中直观地展示不同单词之间的相似度。例如,“student(学生)”与“school(学校)”之间的相似度要高于“student(学生)”与“food(食物)”之间的相似度。

回到 LLM,上下文窗口长度的限制是一个主要挑战。例如,ChatGPT 3.5 的上下文长度限制为 4k 词元(tokens)。这给 LLM 的上下文学习能力带来了显著阻碍,并对用户体验产生负面影响。然而,向量搜索为这个问题提供了一个优雅的解决方案:

  • 将超出上下文长度限制的长文本切分为较短的片段(chunks),并将不同的片段转换为向量(embeddings)。

  • 在将提示词(prompt)输入给 LLM 之前,先将提示词转换为向量(embedding)。

  • 使用提示词向量进行搜索,找出最相似的文本片段向量。

  • 将最相似的文本片段与提示词拼接在一起,作为给 LLM 的最终输入。

这就像给 LLM 配备了一个外部记忆库,允许它从记忆库中搜索最相关的信息。这种记忆能力正是向量搜索所赋予的。如果你想了解更多细节,可以阅读这两篇文章(文章 1 和 文章 2),它们解释得更加透彻。

在 LLM 应用中,向量数据库已成为不可或缺的一部分,其中最重要的原因之一就是它的易用性。配合 OpenAI Embedding 模型(如 text-embedding-ada-002)使用时,只需大约十行代码,就能将用户的查询提示词转换为向量并完成整个向量搜索流程。

def query(query, collection_name, top_k=20):

    # Creates embedding vector from user query
    embedded_query = openai.Embedding.create(
        input=query,
        model=EMBEDDING_MODEL,
    )["data"][0]['embedding']
    
    near_vector = {"vector": embedded_query}

    # Queries input schema with vectorized user query
    query_result = (
        client.query
        .get(collection_name)
        .with_near_vector(near_vector)
        .with_limit(top_k)
        .do()
    )
    
    return query_result

在 LLM 中,向量搜索主要扮演“召回(Recall)”的角色。简单来说,召回就是在候选集中找出最相似的对象。在 LLM 场景下,候选集是所有的文本片段,而最相似的对象就是与提示词最相关的文本片段。在 LLM 的推理过程中,向量搜索被视为实现召回的主要手段。它易于实现,并且可以利用 OpenAI Embedding 模型解决最繁琐的“文本转向量”问题;剩下的就是一个独立、纯粹的向量搜索问题,现有的向量数据库可以很好地完成,因此整个流程显得格外顺畅。

顾名思义,向量数据库是专为“向量”这种特殊数据类型设计的数据库。向量的相似度计算原本是一个复杂度为 O(n^2) 的问题,因为它需要将集合中的所有向量两两比较。为此,业界提出了近似最近邻(ANN)算法。通过使用 ANN 算法,向量数据库采用“空间换时间”的思想预先计算并构建向量索引,从而大幅加速相似度计算过程。这与传统数据库中的索引非常相似。

因此,向量数据库不仅性能强大,而且极易上手,看起来与 LLM 简直是天作之合!(真的如此吗?)

也许通用数据库会是更好的选择?

我们已经聊过了向量数据库的优势与好处,但它的局限性又是什么呢?SingleStore 的一篇博客文章很好地回答了这个问题:

向量和向量搜索只是一种数据类型和查询处理方法,而不是一种全新的数据处理范式的根基。使用专用向量数据库(SVDB)会导致我们在使用多套专用系统的客户身上一次又一次看到(并解决)的老问题:数据冗余、过度的数据迁移、分布式组件之间的数据值不一致、专门技能带来的额外人力成本、额外的软件许可费用、有限的查询语言能力与可编程/可扩展性、有限的工具集成,以及相比真正的 DBMS 而言堪忧的数据完整性与高可用性。

我认为其中有两个问题尤为重要。第一个是数据一致性问题。在原型验证阶段,向量数据库非常合适,易用性高于一切。然而,向量数据库是一个独立的系统,与 OLTP 数据库、OLAP 数据湖等其他数据存储系统完全解耦。因此,数据必须在多个系统之间进行同步、流转和处理。

试想一下,如果你的业务数据已经存储在 PostgreSQL 这样的 OLTP 数据库中。为了使用独立的向量数据库执行向量搜索,你需要先从数据库中提取数据,然后调用 OpenAI Embedding 等服务将每条数据转换为向量,最后再将其同步到专用的向量数据库中。这增加了大量的系统复杂度。更严重的是,如果用户在 PostgreSQL 中删除了一条数据,但向量数据库中的对应向量未被删除,就会引发数据不一致问题。在真实的生产环境中,这类问题可能会造成非常严重的后果。

-- Update the embedding column for the documents table
UPDATE documents SET embedding = openai_embedding(content) WHERE length(embedding) = 0;

-- Create an index on the embedding column
CREATE INDEX ON documents USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);

-- Query the similar embeddings
SELECT * FROM documents ORDER BY embedding <-> openai_embedding('hello world') LIMIT 5;

相反,如果所有操作都在通用数据库中完成,用户体验可能会比使用独立向量数据库更加简单。在通用数据库中,向量只是一种普通的数据类型,而不是一个独立的系统。这样一来,数据一致性就不再是问题了。

第二个问题是查询语言。向量数据库的查询语言通常是专门针对向量搜索设计的,因此在其他类型的查询上可能存在诸多限制。例如,在元数据过滤(Metadata Filtering)场景下,用户需要根据某些元数据字段进行过滤,而部分向量数据库支持的过滤操作符非常有限。

此外,它们支持的元数据类型通常也很受限,往往只包括字符串(String)、数值(Number)、字符串列表(List of Strings)和布尔值(Booleans)。这对于复杂的元数据查询非常不友好。

如果传统数据库能够支持向量数据类型,那么上述问题就迎刃而解了。首先,作为生产环境中已有的基础设施,TP 或 AP 数据库天然保障了数据一致性;其次,查询语言的问题也不复存在,因为向量数据类型只是数据库中的一种数据类型,针对向量数据的查询可以直接使用数据库的原生查询语言(如 SQL)。

深入剖析

当然,只挑向量数据库的缺点是不公平的。支持专用向量数据库通常有以下几个主要论点:

  • 易用性:向量数据库在设计之初就充分考虑了易用性,用户可以轻松上手而无需关心底层实现细节。不过,正如前文所述,将它们与其他数据存储系统集成仍然是一个挑战。

  • 性能:在某些特定场景下,向量数据库相比传统数据库具有显著的性能优势。它们专为向量搜索而设计,能够在大规模高维向量数据集上实现快速、高效的相似度检索。

  • 元数据过滤:虽然向量数据库的元数据过滤能力有所限制,但仍能满足大多数常规业务场景的需求。不过,对于更复杂的元数据查询,可能需要采用混合方案,即将元数据存储在独立的数据库或数据湖中,并与向量数据库中的向量数据进行关联。

该如何看待并应对这些问题呢?在接下来的章节中,我将通过逐一回答这些问题来分享我的观点。

向量数据库确实易于使用

虽然向量数据库确实很容易上手,但这并非它们的专利。向量数据库的易用性主要源于其对特定领域的抽象,使其能够针对机器学习领域最常用的编程语言 Python 进行专门设计,并针对向量搜索场景进行优化。然而,如果传统数据库也支持向量数据类型,它们完全可以提供同等的易用性。

此外,传统数据库既可以提供 Python SDK 和其他集成工具来满足大多数场景的需求,也可以提供标准的 SQL 接口来处理更复杂的查询场景。因此,完全没有必要仅仅为了易用性而专门引入一个向量数据库。

向量数据库的另一个优势是其分布式设计,允许水平扩展以满足用户的数据量和 QPS 需求。但传统数据库同样可以通过分布式架构来满足这些需求。无论如何,是否采用分布式系统应当基于数据量和 QPS 的实际需求以及相关成本来决定。

总而言之,虽然向量数据库有其优势,但如果传统数据库支持向量数据类型,同样可以提供类似的易用性和分布式扩展能力。因此,在向量数据库与传统数据库之间做选择,应当基于应用程序的具体需求和现有资源状况。

向量数据库的性能更好吗?

为了探究向量数据库在 LLM 场景下的性能表现,有人进行了一项向量检索的基础基准测试。该基准测试包含 N 个随机初始化的 256 维向量,并测量了在不同规模 N 下查询 Top-5 最近邻所需的时间。测试采用了两种不同的方法:

  • 使用 Numpy 进行**实时(real-time)**计算,执行完全精确、非预计算的最近邻计算。

  • 使用 Hnswlib **预计算近似(approximate)**最近邻。

基准测试(图片来源)

基准测试结果显示,在 100 万向量的规模下,使用 Numpy 进行实时精确计算的延迟约为 50 毫秒。以此为基准,我们可以对比完成向量搜索之后 LLM 推理所花费的时间。例如,一个 7B 模型在 Nvidia A100(40GB)上推理生成 300 个汉字大约需要 10 秒。因此,即使算上使用 Numpy 对 100 万向量进行实时精确相似度计算的查询时间,它也仅占端到端 LLM 推理总延迟的 0.5%。由此可见,在当前的 LLM 场景中,单就延迟而言,向量数据库带来的收益很可能会被 LLM 自身的推理延迟所掩盖。那么我们还需要考虑吞吐量(Throughput)——而 LLM 的吞吐量远低于向量数据库,因此我认为吞吐量也不是该场景下的核心瓶颈。

如果极致性能并非首要矛盾,那么决定用户选择的因素会是什么?我认为是整体的易用性——包括开发使用和日常运维的便捷性、数据一致性,以及对各类数据库共性问题的成熟解决方案。传统数据库在这些方面拥有久经考验的成熟方案,而专用向量数据库仍处于发展的早期阶段。

元数据过滤能满足大多数业务场景吗?

在考虑元数据过滤时,需要注意的不仅是支持多少种操作符,数据一致性同样是一个关键因素。向量的元数据本质上就是传统数据库中的业务数据,而向量本身则是这些数据的索引表示。因此,将向量和元数据一同存储在传统数据库中是非常合理的思路。

传统数据库完全有能力支持向量数据类型,并提供与向量数据库相当的易用性和分布式能力。更重要的是,传统数据库拥有成熟的机制来保障数据一致性与完整性,例如事务管理以及数据备份与恢复。

传统数据库中的向量支持

既然我们将向量视为传统数据库中的一种新数据类型,那么不妨以 PostgreSQL 为例,看看如何在传统数据库中支持向量数据类型。pgvector 是一个支持向量数据类型的开源 PostgreSQL 插件。pgvector 默认使用精确计算,但也支持构建 IVFFlat 索引并使用 IVFFlat 算法预计算 ANN 结果,通过牺牲一定的计算精度来换取性能。

pgvector 在支持向量方面做得非常出色,并已被 supabase 等产品采用。然而,它当时支持的索引算法较为有限,仅支持最简单的 IVFFlat 算法,且未实现量化或存储优化。此外,pgvector 的索引算法对磁盘不够友好,主要是为内存使用场景设计的。因此,专为磁盘设计的向量索引算法(如 DiskANN)在传统数据库生态中同样具有极高的价值。

由于 pgvector 是用 C 语言实现的,对其进行功能扩展颇具挑战。尽管已经开源两年,pgvector 当时仅有三位贡献者。虽然 pgvector 的实现并不算特别复杂,但使用 Rust 重写它无疑是一个值得考虑的方向。

使用 Rust 重写可以让代码组织更加现代化、更具可扩展性。Rust 的生态系统也非常丰富,已有诸如 faiss-rs 这样的现成绑定库。

pgvecto.rs

正因如此,pgvecto.rs 应运而生。pgvecto.rs 支持精确向量查询操作和三种距离计算操作符,并且围绕索引支持开展了大量设计与实现工作。除了 IVFFlat 之外,我们还希望支持更多索引算法,如 DiskANN、SPTAG 和 ScaNN。我们非常欢迎来自社区的贡献与反馈!

pgvecto.rs 提供了现代化且可扩展的代码库,具备更优的性能和并发能力。其设计与实现允许与其他机器学习库和工具无缝集成,使其成为相似度搜索场景的理想选择。

随着持续的开发演进,pgvecto.rs 旨在成为数据科学家和机器学习从业者的得力工具。它对多种索引算法的支持以及出色的易用性,使其成为大规模相似度搜索应用的有力候选方案。我们期待与社区携手持续推进开发与贡献。

-- call the distance function through operators

-- square Euclidean distance
SELECT array[1, 2, 3] <-> array[3, 2, 1];
-- dot product distance
SELECT array[1, 2, 3] <#> array[3, 2, 1];
-- cosine distance
SELECT array[1, 2, 3] <=> array[3, 2, 1];

-- create table
CREATE TABLE items (id bigserial PRIMARY KEY, emb numeric[]);
-- insert values
INSERT INTO items (emb) VALUES (ARRAY[1,2,3]), (ARRAY[4,5,6]);
-- query the similar embeddings
SELECT * FROM items ORDER BY emb <-> ARRAY[3,2,1] LIMIT 5;
-- query the neighbors within a certain distance
SELECT * FROM items WHERE emb <-> ARRAY[3,2,1] < 5;

未来展望

随着大语言模型(LLM)逐步走向生产环境,底层基础设施的要求变得越来越高。向量数据库的出现是对基础设施的重要补充。我们并不认为向量数据库会取代传统数据库,而是相信它们会在不同的场景中各自发挥所长。同时,向量数据库的兴起也将推动传统数据库更好地支持向量数据类型。

我们希望 pgvecto.rs 能够成为 Postgres 生态系统的重要组成部分,为 Postgres 提供更强大的向量支持。它基于 Rust 的实现以及对多种索引算法的支持,使其在大规模相似度搜索应用中充满潜力。我们相信,通过持续研发与社区贡献,它将成为数据科学家与机器学习从业者手中极具价值的工具。