跳到正文
Modem Blog·· 4 小时前AI 评分38

Modem 用 turbopuffer 替换 pgvector,让整个数据库更快更便宜

Swapping pgvector for turbopuffer made our whole database faster and cheaper

AI 导读

Modem 将嵌入向量检索从 Postgres 的 pgvector 迁移到 turbopuffer,p90 搜索时间降低 76%,并卸掉了数据库的大部分负载。此前共享 pgvector 表让最小客户等待长达 10 秒,2026 年 8 月中位搜索约 0.8 秒、最慢 1% 达 28 秒,而 HNSW 索引因先跨全表检索再过滤 organization_id 和时间范围反而更慢。

正文 · AI 翻译

了解我们共享的 pgvector 表如何让最小的客户变得最慢,为什么 HNSW 索引反而让情况更糟,以及迁移到 turbopuffer 后如何让整个数据库变得更快、更便宜。

和许多其他 AI 产品一样,Modem 使用嵌入。当新的客户消息进来时,我们想知道是否有客户在任何渠道、以任何措辞说过类似的话。嵌入是我们回答这个问题的方式之一。

起步时,我们希望管理的组件越少越好。因此这些嵌入就存放在 Postgres 中,使用 pgvector 扩展。很长一段时间里,这都没问题。

后来我们的搜索开始以一种莫名其妙的方式变慢。一些最小的客户要等上十秒才能拿到结果,而我们最大的客户(要扫描的数据最多)却能立刻得到答案。与此同时,我们的数据库计算成本一路飙升。

在这篇文章中,我将解释是什么拖慢了我们的速度,为什么标准修复方案对像我们这样的产品不起作用,以及我们最终如何认输并切换到 turbopuffer——它把我们的 p90 搜索时间缩短了 76%,并卸掉了数据库的大部分负载。

为什么我们从 Postgres 起步

Modem 会读取你的团队和用户交流的地方(支持工单、问题跟踪器、通话记录、Slack),并把发现的内容归类为话题。每当它从对话中提取出一个新的客户关注点,就会问数据库:最近是否有任何客户说过类似的话?

为了回答这个问题,我们使用嵌入。嵌入是一长串数字,称为向量,由模型根据一段输入文本生成。含义相似的字符串会得到指向相似方向的向量。

Two differently worded messages about the same problem get similar numbers and point in nearly the same direction; an unrelated message gets different numbers and points elsewhere
含义相同的消息会得到相似的数字,因此它们指向同一个方向。

起步时,我们的数据非常少,也没有费心为嵌入建立索引。因此执行这个查询时,Postgres 会查看该客户最近的行,并将每一行与新嵌入逐一比较。

这种暴力方法奏效的时间远超预期。我们的数据库提供商 Neon 慷慨地赠送了免费计算额度,让我们可以超额配置而不是优化,我们也就这么做了。而且由于这些向量搜索作为数据摄取的一部分在多个后台任务中运行,慢查询对用户来说大多难以察觉。

但随着我们增加更多客户、摄取更多数据,嵌入的数量开始快速增长。到 2026 年 8 月,搜索耗时的中位数约为 0.8 秒,而最慢的 1% 长达 28 秒。我们的 Neon 计算资源不断扩容以跟上节奏,其中很大一部分竟然就来自这一个查询。最终免费额度用完了,数据库账单到期了,我们很快意识到自己正坐在一个什么样的问题上。

数据库中慢查询的标准修复方法是添加索引。但在测试中,这个索引不仅没有提升性能,反而让性能显著变差。

HNSW 索引如何工作

pgvector 中的标准向量索引是 HNSW,全称是 Hierarchical Navigable Small World。许多向量数据库都支持它,向量搜索正是靠它变快的。

HNSW 不会将你的查询与每一行比较,而是构建一个图,其中每个向量都链接到其最近的邻居,并按从稀疏到完整的层次堆叠。搜索从顶层开始,朝最近的点跳转,下降一层,然后重复。你只需查看几百行而不是整张表,答案是近似的,但通常非常好。

A man holding up a framed photo of a green Victorian house in San Francisco
看看这栋房子,你会如何找到更多类似的房子?

让我举个例子。假设给你一张房子的图片,并要求你找出一组看起来像它的房子。你会从世界各地分布的几栋房子开始,然后走向最像你图片的那一栋。

假设第一个命中是旧金山的一栋房子。然后你会查看旧金山附近更密集的更多房子,并走向最像它的那一栋,这可能会把你带到像 Pacific Heights 这样的街区。最后,你会在那里的街道上走动,直到周围都是看起来像你图片的房子。这大致就是 HNSW 找到相似向量的方式。

Three layers of the same houses: a few far apart, more closer together, then every house. The search hops to whichever house looks most like the picture, drops a layer each time it gets close (San Francisco, then Pacific Heights), and ends on a cluster of houses that look like the picture.
HNSW 的工作原理:在稀疏层上大步跳跃,接近后则小步移动

意外:HNSW 反而可能让性能变差

如果 HNSW 能自行回答你的查询,那它很棒。但我们的查询有两个额外的过滤条件:

  • 数据所属的 organization_id
  • 一个时间范围(用于时效性)

通常,Postgres 会为你组合索引。你有一个用于 organization_id 的索引,一个用于 timestamp 的索引,查询规划器从每个索引中取出匹配的行,保留同时属于两者的行,然后继续。这一切都会自动发生,你无需操心。

SELECT ... FROM semantic_groups
WHERE organization_id = $org_id        -- Organization filter
  AND timestamp > now() - $time_window -- Time range
ORDER BY embedding <=> $candidate
LIMIT $count;

但 HNSW 不是这样工作的。常规索引回答的是“哪些行匹配?”HNSW 回答的是“哪些行最接近?”而“最接近”适用于向量空间中的所有内容,忽略任何其他过滤条件。换句话说,Postgres 首先在整个表上遍历图,之后才应用你的常规列过滤条件。

在由许多客户共享的多租户表上,这是一场灾难。向量查询从整个表中返回固定数量的候选匹配,然后 organization_id 和时间过滤条件会丢弃所有不属于目标组织或时间范围的内容。这通常意味着原本看起来有希望的候选结果被缩减为空集。

回到找房子的例子。假设你仍然想找到看起来像输入图片的房子,但你有了一个新条件:你只想考虑待售的房子。你像之前一样搜索:旧金山、Pacific Heights、一条满是看起来像你图片的房子的街道。只有到那时你才检查招牌。那些房子没有一栋待售,所以你空手而归,尽管沿街往下几栋就有待售的。

The same search, then a filter for houses that are for sale. The walk finds a cluster of houses that look like the picture. The for-sale filter runs afterwards and removes every house that isn't for sale, including the whole cluster, so no matches are left. The houses for sale were elsewhere on the street.
同样的搜索,只保留待售的房子。

这是 pgvector 的一个已知问题。以至于它有一个设置 hnsw.iterative_scan,会不断拓宽遍历范围,直到有足够的行通过过滤条件。

我们在几千次真实生产搜索上将其与暴力扫描进行了对比测试。

  • 拥有少于 500 个组的客户中位数速度慢了 89 倍,9.1 秒对 103 毫秒。
  • 约 7% 的结果发生了变化,包括数十个此前已被人确认相关的匹配。
  • 最慢的搜索耗时 178 秒。
  • 这些索引几乎让表的存储空间翻倍。

客户越小,遍历就需要在图里跋涉越多才能找到它的行。而且由于我们是一个提供免费套餐的自助式产品,我们的大多数客户都很小。

所以我们撤掉了索引,回到暴力扫描。这仍然留下了最初的问题。为什么它一开始这么慢?

为什么我们最小的客户最慢

随着我们继续挖掘,我们有了一个奇怪的发现。

一个大客户全天不断向我们发送反馈,其检索时间中位数约为 1 秒。而另一个规模小得多的客户定期向我们发送数据,其中位数却超过 10 秒。这感觉完全反了,因为在数据库领域,更常见的情况是大客户(要扫描的数据更多)查询更慢。

The big customer holds far more data but its median search takes about 1 second; the small customer holds little data and its median search takes over 10 seconds
数据量少得多的客户却等了十倍长的时间。

那么,到底发生了什么?

繁忙的客户和安静的客户运行的是完全相同的查询。区别在于查询运行时他们的数据恰好位于何处。

Postgres 将最近使用的数据保留在内存中,而每个客户共享这块内存。我们的嵌入向量足够大,以至于一次只有一小部分能装入内存。繁忙的客户搜索足够频繁,使其数据行保留在内存中。安静的客户回来时发现自己的数据行已被其他人挤出内存,因此每次搜索都要访问磁盘。

Memory is shared by every customer. Each time the busy customer searches, its rows take a slot and push one of the quiet customer's rows down to disk
繁忙客户的搜索把安静客户的数据行挤到了磁盘上。

扫描本身没有问题。它始终给出正确的答案。问题在于每一行的读取成本都很高,而其中大部分现在都来自磁盘。

最后一次尝试:缩小向量以装入内存

此时我们已经怀疑 pgvector 能否跟上 Modem 的增长。我们只是想在我们需要更换基础设施之前为自己争取时间。

如果 HNSW 行不通,而问题在于我们的嵌入向量太大无法留在内存中,我们能否使用小得多的量化嵌入向量来创建一个替代索引?

其论点是,一个足够小的向量索引可以完全保留在内存中,磁盘上什么都不留。所以我们采用了尽可能小的方案:二值量化。

pgvector 有一个用于此目的的函数 binary_quantize(),它通过仅保留其 1,536 个数字中每个数字是正还是负来缩小嵌入向量。这用一个比特位表示,即 1 或 0。

embedding  [ 0.021, -0.013,  0.008, -0.044,  0.017,  0.030, -0.002, ... ] // N = 1,536
quantized  [     1,      0,      1,      0,      1,      1,      0, ... ] // N = 1,536

这将 3 KB 缩减为 192 字节,并且比较其中两个只需快速计数比特位,而不是 1,536 次乘法。这显然会损失大量精度。但是,由于嵌入向量占用的空间小得多,且比较速度如此之快,你可以过度获取并比较更大范围的候选集。

最后一次回到房子的例子。这就像快速翻阅一叠每栋房子的微小模糊照片,挑选出一批看起来大致正确的,然后开车过去看看这些匹配是否真的合适。

The same house twice. On the left, the original sharp photo. An arrow points to the right, where the same photo is heavily blurred, the quantized version.
同一栋房子,大部分细节已被丢弃。

我们将这些量化嵌入向量保存在它们自己的小表中,它装入内存后还有富余空间。而且由于这只是一个普通的 Postgres 表,我们可以为 organization_id 和 timestamp 等列建立索引,这样查询规划器就能像你预期的那样生成高效的查询。

因此每次搜索都像这样:

  1. 与客户最近的每一个量化嵌入向量进行比较,并保留最接近的 N 个候选,其中 N 大约是你最终想要得到的实际目标数量的 50 倍。
  2. 对全精度嵌入向量运行我们最初的精确查询,但仅针对那个候选短名单。
Two rankings of the same shortlist. In the quantized rank the three true matches sit at 2, 6 and 11. In the full precision rank they move to the top three. Keeping only the top three quantized results would miss two of them, which is why the shortlist is much bigger than the final answer.
量化索引将匹配项送入候选短名单,而不是直接送到名单顶部。

它奏效了。一个回放测试工具以两种方式运行了数千次真实的生产搜索,量化索引返回的结果与暴力扫描相同。它还削减了尾部延迟。在一周的生产流量中,p90 从 7.6 秒降至 3.5 秒,p99 从 28.1 秒降至 6.3 秒。中位数则相反,从 0.81 秒升至 1.16 秒,但造成麻烦的正是那些慢速搜索。

这确实是一场实实在在的胜利。搜索变快了,结果却没有变化。但完整的 halfvec 嵌入仍然存放在 Postgres 中,而且即便有了候选列表,每次搜索仍然必须读取大量嵌入。要让这些读取保持快速,就得把它们留在内存里,所以我们仍然在为远超应用其余部分所需的数据库付费。换句话说,查询变快了,但我们的 Neon 账单并没有变小。

迁移到 turbopuffer

就在那时,我们不再试图让 pgvector 成真。

是时候去做我们内心深处一直知道必须做的事了:切换到专用的向量数据库方案。我们选择了 turbopuffer。

The turbopuffer logo: a pixel-art pufferfish next to the word turbopuffer

turbopuffer 是一个专有搜索数据库,由两位前 Shopify 工程师于 2023 年创建,以付费 SaaS 服务的形式提供。它支持向量搜索和全文搜索,并将所有数据保存在 S3 之类的对象存储上,同时在前端用内存和 SSD 缓存当前正在被搜索的数据。这样一来,保留大量向量的成本很低,而搜索正在使用的向量又很快。

turbopuffer 解决了我们问题的两个方面。它在数据该在的地方处理过滤,即在搜索内部,而不是在搜索之后。它还把嵌入移出了 Postgres,放进一个围绕“大多数向量大多数时候都是冷的”这一理念构建的系统里。

每个客户都有自己的命名空间,因此他们的向量存放在各自的索引中。所以从一开始,我们就只查看该客户的搜索空间。这还带来了额外好处:账户之间的数据隔离更好。

Two searches for one customer's five closest rows, both starting from the same table. pgvector takes the closest five first, then filters by customer and is left with one. turbopuffer filters by customer first, then takes the closest five and keeps all five.
在搜索之后过滤,还是在搜索之前过滤。

我们在每个向量上存储时间戳,因此 turbopuffer 只搜索时间窗口内的向量。pgvector 的 HNSW 索引做不到这一点。它先找到最近的若干行,然后再检查它们的日期,并通过迭代扫描不断查找,直到有足够多的行通过筛选。我们的量化表把日期处理对了,但为此必须与窗口内的每一行进行比较。

逐步上线

切换本身很小,因为我们已经花了一个月时间在量化索引上。我们的搜索本来就已经分两步运行:先构建候选列表,再对其进行精确排序。而且我们有回放测试工具来判断候选列表是否足够好。我们唯一改变的是候选列表的来源。

我们把每个向量都写入两个系统,并让每个客户都在影子模式下运行。Postgres 仍然负责真实响应,turbopuffer 在旁边运行同样的搜索,以便我们比较每一个结果。88% 的结果完全相同。另有 11% 存在细微差异,比如两个几乎相等的匹配以不同顺序返回。

最后 1% 主要来自拥有一个超大主题的客户。关于同一件事的数百条消息会占满一次搜索,把其他内容挤出去。要求 turbopuffer 每个主题只返回一个结果就解决了这个问题,剩下的 0.43% 是同样优秀的匹配之间的并列。

结果

在有了信心之后,我们把所有人都切换了过去。以下是三种方案下每条新反馈的搜索时间,每种方案都基于约一周的生产流量测量。

百分位每条新反馈的搜索时间每种方案约一周的生产流量
p50暴力搜索0.81 秒量化1.16 秒turbopuffer0.86 秒
p90暴力搜索7.6 秒量化3.5 秒turbopuffer1.8 秒
p99暴力扫描28.1 秒量化6.3 秒turbopuffer4.1 秒

中位数几乎没有变化,因为大多数搜索从来都不是问题所在。变化发生在尾部。与暴力扫描相比,p90 下降了 76%,p99 下降了 85%:最慢的 10% 搜索快了约四倍,最慢的 1% 快了近七倍。

关于这些数字的说明。它们是我们流水线所见的完整往返时间,包含网络,而不是数据库在查询上花费的时间。而且我们有些搜索会要求 turbopuffer 返回精确结果,这意味着要与该客户最近的每一行进行比较,因此客户的数据越多,这些搜索耗时就越长。对于后台任务,我们宁愿正确也不愿快。

这是切换的那一周,逐小时。

单次搜索中最慢的 10%,逐小时。在影子模式下两个系统都运行它;上线时 turbopuffer 接管。

请注意,此时 Postgres 比我们最初引入量化索引时更慢。表已经增长,而且我们为新功能添加了第二个搜索,它仍然以暴力扫描方式运行,并争夺同一份内存。这说明了为什么我们需要一个更好的长期解决方案;围绕一种查询模式写一个临时修复,并没有考虑到我们沿途会引入的新查询。

它对 Postgres 的影响

Postgres 拿回了它的内存。在最初的 24 小时内,我们生产数据库的 CPU 下降了 62%,缓存未命中下降了 82%,表上的慢查询下降了 95%。Postgres 中的其他一切也变快了,因为这个昂贵的向量查询不再占用所有内存。我们得以缩减 Neon DB 实例规模并降低数据库账单。

尽管我们现在还要为 turbopuffer 付费,但切换到这种双系统使我们的整体存储账单——服务所有 Modem 数据,而不仅仅是嵌入——下降了约 10%。

我们所有 Modem 数据的存储账单,相对于切换前。

我们会怎么做不同

这一切都不在计划之中。我们一开始想要一个数据库,最终却有了两个。

我们仍然会从 Postgres 开始。一个数据库要运行的东西更少,而且在小表上暴力扫描能撑的时间比你想象的要长。但回顾过去,有三件事本可以让我们更早走到这一步:

检查谁慢,而不仅仅是多慢。 我们的中位数看起来没问题。只有当我们按客户拆分搜索时间时,才看到那些小而安静的客户等待时间长了十倍。如果你最慢的搜索属于你最小的客户,先看内存,再看查询。

在信任索引之前,用真实搜索测试它。 带过滤器的 HNSW 可能返回不同或更少的结果,而且不会报错。我们之所以发现,是因为我们用真实的生产搜索运行它并比较了答案。先为此构建一个测试框架。我们的框架比本文中的每一种方法都活得久。

计算向量对你数据库其余部分的成本。 量化索引让我们的搜索足够快。它做不到的是阻止嵌入把其他一切挤出内存,而我们一直在为数据库的过度配置买单。

解决问题的方法是把嵌入移到它们不会与其余数据争夺内存的地方。

事实证明,多一个数据库比多一个过滤器更简单。

来源:Modem Blog · modem.ai