CheinTian / notes
Clipping · 20 分钟

为什么我看好 Turbopuffer,从 Personal Agent 说起

江湖小虾米

📝 摘要

文章从 Personal Agent 的长期记忆出发,质疑「做一个通用 Memory API 卖给所有 Agent 产品」的推论,认为记忆的写入、检索与呈现高度依赖具体产品与 Agent 的工作方式,而更底层的搜索/索引 building block 更容易被广泛采用。作者以 Turbopuffer 为主线,分析其 S3-native 架构:持久数据放在对象存储、query server 由大量 namespace 共享、冷查询从对象存储读取并按需缓存,从而服务「租户极多、单租户数据不大、访问极不均匀」的 Personal Agent 负载。文章还深入讨论了冷查询代价、WAL 与索引构建的最终一致性、SPFresh 聚类索引与 BM25 postings 在对象存储上的分块组织,以及作者自己实现 Todaypuffer 时踩到的单体索引、全量扫描与 OOM 问题。最后,作者借 Turbopuffer v3、Linear delta sync、pgvector 与 Mem0 案例,说明搜索系统正从单一向量检索走向更通用的查询基础设施,并指出 agentic search 可能用模型替代一部分预建索引的工作。

核心概念: Memory 是产品设计、工程架构与模型互相耦合的产物,难以用通用 API 抽象;相比之下,搜索/索引是边界更清晰、更可复用的基础设施层。

关键技术栈 / 关键要点:

  1. Memory API 的局限 — 记忆的写入提炼、冲突呈现与调用时机都依赖具体 Agent 行为,通用 add/search/update 接口无法解决;检索结果是否进入本次 context,也由 Agent 的工作方式决定。
  2. Personal Agent 的负载特征 — 一个用户或独立 Agent 近似一个租户,租户数多、单租户数据小、访问时间极不均匀;为每个租户常驻索引进程/缓存会把钱花在等待上。
  3. Turbopuffer 的 S3-native 架构 — namespace 持久数据放对象存储,query server 共享,冷查询按需读取并缓存;代价是冷查询延迟(百万文档首次 p50 874ms,缓存命中后 14ms),可用产品交互预热来掩盖。
  4. 对象存储上的索引设计 — HNSW 一类图索引不适合远端对象存储,Turbopuffer 用 SPFresh 聚类索引批量读取;BM25 倒排索引需按词项/块切分,尽量减少远程读取次数与数据量。
  5. Todaypuffer 的实践教训 — 单体 BM25 JSON 索引在十万文档时退化为全量扫描(单请求约 24 秒并触发 OOM),拆成 256 个 term bucket + manifest 后 warm 查询回到十几毫秒。
  6. 从向量检索到通用查询系统 — Turbopuffer v3 将 ANN 降为二级索引,全文、属性过滤与 GROUP BY 成为一等公民;Linear 用其做 delta sync 的集合求交,Mem0 用其做记忆索引,说明客户会长出相邻需求。

关键结论 / 实践建议: 用户规模不大时,Postgres + pgvector/vectorchord 往往比引入独立搜索系统更省事;只有当「大量长期存在、大部分不活跃、但查询到来时要快速找到多年以前内容」的数据集成为主要分布时,S3-native 搜索基础设施才真正划算。搜索的底层技术变化不大,但应用形态变化带来了新的权衡;未来是否会出现原生支持版本化的搜索与记忆系统,值得关注。


提到 Personal Agent 的数据基础设施,很多人会从 Memory 开始。如果一个人用 Agent 几年,留下来的聊天、文件、任务记录会越来越多。以后还想让 Agent 接着做事,就要有办法从历史里找到需要的部分。看上去每个 Agent 产品都需要 Memory,似乎也就该有一个可以卖给所有产品的 Memory API。我对后面这个推论一直有点怀疑。

以写作为例,一篇文章里出现的判断,有时是作者在陈述自己的看法,有时只是在试着把一个念头推到尽头,是在做推演。Agent 把段落存下来,给它生成 embedding,下次被问到相关话题时找回来,这些都可以做得很好。但要不要把它写进新文章,尤其能不能用,你之前一直认为,这样的口吻说出来,得看那段话在当时的写作过程中是什么位置。甚至已经发表的文章也未必代表作者今天还同意那时候的观点。

这篇文章本身就可以拿来做例子。我先跟 Today 说记忆不是一门好生意,后来讨论 Turbopuffer、Mem0 和 Personal Agent。如果 Memory 服务从第一次讨论里抽出一条,用户认为 Agent Memory 没有商业价值,它存下来的某种意义上是对的,但以后拿出来用很容易出错。把新观点直接覆盖上去也有损失,因为我改变想法的过程恰好是写文章时需要的材料。保存原文、时间和来源并不难,但是决定当前这次写作需要看到哪一段,以及该怎样理解它,是一个非常需要智能的事情。

很多 Memory 产品的接口最终会收敛成几种操作:添加一条记忆,搜索相关记忆,更新或删除。接口是挺清楚的,但是本身记忆是一个产品设计和工程架构和模型互相耦合的事情。什么值得在写入时提炼成长期记忆,什么只需保留原始记录;两条相反的历史该怎样呈现;Agent 在什么任务里应该主动提起用户过去说过的话。很难凭一个通用 API 的定义解决。

还有一个经常被忽略的地方是,检索结果被找出来,并不意味着它应该进入模型这一次的 context。假设 Agent 正在帮我检查这篇文章里的一个技术判断,它找到十段我以前谈过 Search 的文章片段,可能还得沿着其中一段去找对应的测试报告。另一次我只是让它改一个句子的语气,这十段历史全部塞进来反而碍事。Memory 服务可以对哪些更加相关做优化,读哪些、读到什么程度,最后仍然会被 Agent 的工作方式决定。这个领域实际上 WebSearch 有挺多公司已经有了探索,在 Search 上接一个小模型引入一些智能来做更加复杂的排序和筛选,类似的思路也可以用在 Agent 的 Memory 上。

推荐系统有一点类似。内容召回需要的存储、索引和计算能力可以由基础设施提供,最终给用户看什么,跟产品目标、用户当时在做什么、甚至这个位置是搜索页还是首页有关。到了 Agent,检索系统可以返回一段历史和它的出处,却没法仅凭相似度判断这段历史应不应该变成 Agent 对用户的一个断言。我并不是说 Memory API 卖不出去,Mem0 就是一家在做这件事的公司。只是沿着这条链往下看,我觉得 Search 的需求更容易说清,并且这样的底层的 building blocks 更容易被采用。这就像是如果你做一个粗排系统,是很难卖给互联网厂商的,因为这个跟具体的产品目标和用户行为紧密相关。但是你做一个给召回系统的 data lake 或者其他的基础设施,反而更容易被广泛采用,因为它解决的是通用的存储和计算问题,而不必关心每个 Agent 的具体行为。

这种区别还会影响产品怎么评估自己。一个搜索服务是非常聚焦的,关注的指标就是 recall 和成本,和可编程性。一个 Memory 产品最终的评估就非常复杂:Agent 有没有误把试探性的想法说成用户的稳定偏好,能不能遗忘,能否在适当的时机提起历史。前一组指标并不能替后一组指标作答。这不是说上层产品没有价值,恰好相反,上层那部分价值太贴近具体产品,很难假设所有客户都愿意把它交给同一个供应商。

Personal Agent 给记忆召回带来的负载挺特别。一个用户,或者一个独立运行的 Agent,可以看作一个租户。每个租户的数据未必很大,租户数却可能非常多,而且使用时间很不均匀。今天跟 Agent 聊了很久的人,接下来两周都不打开产品,他的数据还是得留着。几年以后,他也许突然问起两年前的一次讨论,不能因为那份索引一个月没人碰过,就回答说找不到。

这里容易把两个东西混在一起。用户数增长,意味着需要长期保存的数据集增长。同一时刻正在查询的人数,增长速度未必一样。如果给每个用户分一个常驻的索引进程、缓存甚至数据库实例,大量钱会花在等待上。这个是 Muse 的做法,他的 confidencial vm 里跑着 pgvector 和一个 minilm embedding model 和一个 jina reranker。如果把所有人的记忆塞进一个巨大的共享索引,查询又必须先处理租户范围和权限。向量索引尤其麻烦:先找出全局最相近的内容再按用户过滤,可能发现候选里没几条属于这个用户;先把属于这个用户的数据过滤出来,再逐条算距离,用户数据多时又会慢。

这些问题不意味着一开始就需要新的数据库。用户还不多的时候,把对话和任务存进 Postgres,做必要的全文索引,向量需求出现后再考虑 pgvector/vectorchord,大概率比引入一套独立搜索系统省事。原始数据、事务和备份都已经在那里。一个人的历史还很短时,查出最近几十条记录交给模型读,也完全可能是最好的办法。真正值得研究的是另一种分布:很多长期存在的独立数据集,其中大部分平时不活跃,但查询到来时又希望很快找到多年以前的内容。此时存储成本、每个租户固定的索引开销、热租户抢缓存的方式,开始一起影响价格和延迟。

Turbopuffer 最初抓住的就是这样一类需求。它把 namespace 的持久数据放在对象存储里,query server 由许多 namespace 共用。一个很少被查的 namespace 不必为了保留索引一直占着某台机器的内存。请求来了,节点从对象存储读取这次需要的数据;随后再查,内存和 local ssd 上的缓存就能派上用场,路由也会尽量利用已经缓存过它的节点。机器出了问题,其他节点仍可以从对象存储恢复读取。这个结构并没有让计算消失,只是让计算资源跟实际查询更接近。

冷查询因而是必须正视的代价。Turbopuffer 架构文档 给的一个百万文档例子,第一次查询 p50 是 874 毫秒,缓存命中后是 14 毫秒。这是官方在特定负载下展示的数字,不能当成所有 namespace 的延迟保证。Notion 的 客户案例 里还提到,他们在打开可能会使用搜索的对话框时先发一次预热查询。用户还没真正开始搜索,系统就借产品里的这个动作,提前把冷数据读进缓存。我觉得这个细节比单独看一个 warm p50 更有意思:有些冷启动可以由产品交互来掩盖。在 personal agent 场景也有类似的机会。

再顺着一次写入看。Turbopuffer 先把同一 namespace 的写入合并提交到对象存储中的 WAL,之后由独立节点构建索引。WAL 已经提交、索引还没跟上时,查询会补扫这段新数据,所以一般能够读到刚写入的内容。它的文档也写了这个机制的边界:一个 namespace 积压的未索引写入超过一定大小后,后续写入的可见性要等索引追上。 官方保证说明 专门列了这个限制。把持久化和索引工作拆开,让写入不必等整份索引更新完;索引节点如果长期落后,查询节点就得替它做越来越多的工作。这种索引的最终一致性的设计,对于绝大多数场景都是可接受的。

它的索引的存储布局也是按照 s3 native 的思路设计的。以向量检索为例,HNSW 一类图索引会沿着邻居关系不断跳转;数据全在内存里时这种访问很自然,放在远端对象存储上,跳得多就意味着更多等待。Turbopuffer 用的是 SPFresh 这类基于聚类的索引:先确定可能相关的聚类,再批量读取其中的数据。全文检索里,BM25 的倒排索引也不是把一份大表扔进 S3 就完事。查询只涉及几个词,理想情况下就应该尽量少读无关词的 postings。对象存储按请求和搬运的数据量收费,远程读取还带着固定延迟,索引文件如何分块,跟 BM25 本身一样会影响最后的服务成本。

我最近几天也在为我们的产品做一个类似 Turbopuffer 的实现,姑且称之为 Todaypuffer。动手之前,我觉得索引放进对象存储大概已经概括了主要设计。仔细研究了一下才发现真正复杂的地方在于,如何在对象存储的约束下高效地组织和访问索引文件。怎么样尽可能减少远程读取的次数和数据量,同时保证查询的延迟和吞吐。

Todaypuffer 早期的 BM25 索引是一份单体 JSON derivative。四万条 MS MARCO 文档时,这份文件已经有约 46 MB。我当时给单体索引设置了 64 MiB 的写入上限;十万文档的旧快照里没有持久化成功的 BM25 索引,查询退回扫描原始数据。一次请求花了约 24 秒,混合查询的测试还把机器推到了 OOM。后来把 term postings 拆到 256 个 bucket,用一份 manifest 指向它们。查询有了词项以后,只取这些词对应的 bucket,排出候选文档,最后再回到已提交的原始数据取结果。十万文档的这份索引落成了 257 个对象,总量约 60 MB。它至少让这批数据摆脱了前面的全量扫描。在同一份测试材料上,warm BM25 回到了十几毫秒。数据存在 S3 上和数据能被有效查询,是两件需要分别实现的事。

当然 256 个 bucket 也不是终点。现在的查询仍会解码整桶,再从里面取需要的 postings;它还没有利用块级跳过和更细的 range read。每次产生新的 namespace head,也需要为它构建新的 derivative。如果一个用户的历史继续长大,或者一批用户同时更新,索引构建的 CPU、对象写入以及查询等待新索引的时间,都会重新变成问题。

Turbopuffer 还有一个很重要的特性,是支持海量的 namespace。单个热 namespace 的缓存和机器内存,不等于大量冷热交替租户的缓存命中率;一次服务端 p50,也不包括索引任务积压、节点故障后的重新加载、S3 请求量和为了保证冗余而空着的机器。我后来在小机器上做过缓存调整,减少缓存确实省了部分内存,但同一批查询在持续负载下也变慢了。这里 turbopuffer 应该是做了很细节的 tradeoff 设计,平衡了缓存分配、索引构建和远程读取的成本。

一个比较有意思的地方是,Turbopuffer 的客户里还有 Mem0。按照 Turbopuffer 披露的 案例 ,Mem0 把原始 memories 放在 Postgres,把文本、embedding、元数据和指回原始记录的 ID 放进 Turbopuffer。检索先在 Turbopuffer 完成,再去 Postgres 取命中的完整内容。所以实际上卖记忆的公司,最终也是需要这么一个 building block 来高效处理大规模的索引和查询。

Mem0 的迁移还把多租户问题说得比较具体。它原本在一张 Postgres 表上用 HNSW,查询却要同时限定 organization、project、user、agent、session。先按向量找候选再过滤身份,可能走了很久才凑够这个用户的结果;先按身份筛出记录再算距离,剩下的数据多时又会慢。后来它按客户建立独立的 Turbopuffer namespace。这样每次向量检索面对的就是该客户的索引,不必从全局候选里再捞出属于他的几条。有点难受的是之前我创业时候的产品 vectorchord 就是为了解决这样的问题存在的,但是我们并没有接触到足够的客户。

这里还不能把一个用户是一个租户简单地类比成一个用户一个 namespace。一个人自己的聊天和与团队共享的文件,权限并不相同。有些查询只看个人历史,有些要同时看个人和团队资料;有些数据会随着用户离开组织而失去访问权。namespace 是搜索系统的隔离和管理单位,不必跟产品里的 user ID 一一对应。这里的权限设计,实际上可能有新产品的机会。

研究到这里,我原来想写的结论只是 Search 比 Memory 更像基础设施更有商业化空间。Turbopuffer 最近的一篇文章让我觉得,这个结论可能还下得太早。 RIP, vector database 里提到 Turbopuffer v1 只有 ID 和向量,存储布局围绕 ANN 索引中的地址组织。后来加入属性过滤和全文搜索,其他索引也得指向这些地址。这个设计服务向量查询很直接:找到了向量的位置,也就能找到它对应的文档。麻烦是向量聚类会变化。SPFresh 为了维持检索质量移动向量时,文档内容和引用这个地址的其他索引也可能跟着搬。只更新一个向量,最后变成了不少与向量距离无关的数据写入。

多向量表示会让这个关系更别扭。同一篇文档如果对应多个向量,文档内容可能随这些向量重复保存。做全文检索时,postings 最合适的块大小也不一定跟向量聚类相同。Turbopuffer 在文中提到,它较早的全文索引沿着 ANN 聚类分块,块里实际只有很少的 postings;后来把 postings 改成固定大小的块,全文索引明显缩小,查询也快了。到了排序、扫描和 GROUP BY ,查询甚至可能根本不关心最近的向量是谁,但是还需要尊重为向量设计的物理布局。

所以他们现在做 v3,准备让 ANN 变成二级索引,把文档的主要存储布局从向量地址里解放出来,改用更适合批量读取和列式执行的组织方式。过去最重要的查询,已经不应该继续决定所有数据的布局方式。10 月的 开发日志 里,按属性分组计数比 v2 快了约十五倍;同时一些全文检索测试还比生产版慢,团队仍在调性能。

客户已经在拿它做向量搜索以外的事。 Linear 的 delta sync 就没有相似度计算。Linear 的客户端会在本地保存数据,离线一段时间再回来时,带着上次处理到的 checkpoint,请服务端告诉它后来发生了哪些变化。大的 workspace 每天可能产生非常多的变更;客户端需要的却只是它有权看到、也订阅了的那一小部分。旧的 Postgres 路径要在越来越长的 ID 范围里检查记录,再逐条判断权限组和订阅条件,尾部延迟开始不稳定。加 read replica 可以分担请求,不能减少单个请求里这份筛选工作。

Linear 把 sync action 的较轻元数据放进 Turbopuffer,让权限组和订阅条件各自对应一组排好序的 ID。查询时先合并用户所属组对应的 ID,再和订阅内容、checkpoint 之后的 ID 范围求交集,拿到少量真正需要的 action,完整内容仍从 Postgres 取。这里买到的是一种处理巨大集合交集的读取能力。它既不是语义搜索,也不需要模型参与,却沿用了文本和属性检索里很重要的倒排索引思想。

把读取搬到另一套系统,最让人担心的是新写入还没同步过去。Linear 没有假设索引永远实时:Postgres 负责日志最新的一小段,Turbopuffer 负责后面更长的历史,两段故意重叠,再按 action ID 去重。Turbopuffer 不可用或覆盖不到请求的范围,就回退 Postgres。上线前他们还把新旧路径同时跑,比较返回的 action ID。

Turbopuffer 最初用很明确的向量搜索需求找到了客户,后来客户把全文、过滤以及 Linear 这样的在线读取路径放上来,产品开始走向更通用的查询系统。起点窄的好处是架构和销售对象都很清楚,最初的性能与成本差异也容易说服人迁移。代价是客户长出相邻需求以后,那些为第一类查询做的好设计可能变成约束。

另一边是从通用数据库往专门的检索能力走。数据本来就在 Postgres,装上 pgvector,继续使用 SQL、事务和现有运维体系,对很多产品来说少了大量额外工作。pgvector 也有 HNSW 和 IVFFlat, 文档 讨论了近似索引遇到过滤条件时怎样影响结果。Postgres 不是一个固定不变、只能靠常驻大机器服务的参照物;Neon 这样的服务早已经在做计算休眠和存算分离。通用数据库让应用少一次同步,业务状态与查询放在同一套事务体系里。Linear 愿意承担双写同步、重叠读取和故障回退,因为 delta sync 路径已经很贵。对于一个刚上线的 Agent 产品,这些工程工作可能比直接 Postgres 贵得多。

回到 Personal Agent,Memory 这个词已经不太能概括它会接触的内容了。写文章时,它可能要翻旧草稿、已发表的版本和当时讨论的 Session。在你的未读邮件里,有些来自重要的人,却早就埋在营销邮件下面。这两件事都需要检索,但找出一段相似的文字,离完成任务还有距离。比如从未读邮件里找出一封重要的信。索引里有发件人、时间、正文和邮件 ID,Agent 可以先用它找到那封信。但如果接下来要替我回复,它还得去邮箱重新读取整条邮件往来,确认对方后来有没有补充、我是否已经回复。 Glean 的连接器文档 区分了提前建立的索引和请求发生时对来源的实时调用,具体怎么组合取决于来源和连接器。这里的索引帮 Agent 从几千封邮件中找到候选,邮箱本身仍然决定最新的邮件往来是什么。未来很多问题可能都是复合的,agentic search 实际上是在用模型解决 index 之前解决的问题。如果 context window 足够大且足够便宜,那么是没有 index 存在的意义的。

我让 Agent 改一篇文章,它要找的往往就是最近几个 Session 和草稿。直接打开原文,把几次修改放在一起看,比从一条压缩过的记忆里还原我当时的想法靠谱得多。尤其我已经给出了文章标题,或者它知道我们刚才在改哪一个文件,现场读取几份材料的成本可能很低。为这种请求事先构建复杂索引,还要处理每次改稿后的同步,未必合算。

实时召回其实有两种。如果 Agent 调用的是邮件或文档系统自带的搜索,索引工作已经由那个系统做了,Agent 只是借它找到候选。如果它只能沿着目录和会话一页页翻,边读边判断下一步去哪儿,那就是把一部分索引工作交给模型,在每次提问时重新做一遍。 Anthropic 25 年写 context engineering 的文章 也讨论了这种按需探索:它可以少做预处理,让 Agent 自己决定往哪里读,但运行时会更慢,也可能在无关材料里浪费 context。

等 Agent 跟一个人相处了几年,问题可能就是简单的读不读原文,而是先要知道原文在哪。比如我问它,自己是什么时候开始怀疑 Agent Memory 是一门好生意的。这个想法可能散在几次讨论、两版文章和后来的一次修改里。让模型从头读完这些年留下的所有东西,代价很难接受;提前留一个能定位到原文的目录,哪怕只记来源、时间和一些粗略的内容线索,就能省掉大量试探。找到之后,它仍然得读当时写下的话,也要分清那是我现在的判断,还是已经改掉的旧观点。索引不必在写入那一刻替我决定哪个观点才是真的。

搜索目前看起来技术是没什么大的变化的,最后还是来来回回几种索引方式,只是随着应用的形态变化,有了一些新的权衡可以做。未来会不会出现原生支持版本化的搜索和记忆系统,是一个挺有趣的话题的。