Linguista

真正优秀的搜索系统实属难得

图像

我设计了一个多租户搜索引擎,它允许在毫秒级内对不断变化的文档进行智能查询。本文概述了构建 Index 的过程,这是一个面向 Agent 的动态语料库检索系统。

背景 (Background)

语境 (Context)

搜索系统通常不是为 Agent(智能体)构建的,而是为人类构建的。这是因为人类具有适应能力。人类会在探索搜索空间时建立心智模型,发现边界,识别不一致之处并记住如何绕过它们,且随着时间的推移不断优化他们的搜索方式。

但 AI 做不到这些。Agent 在语料库 (corpus)中搜索时缺乏直觉,且单次搜索并不能帮助它们建立对正在处理的语料库的进一步理解。即使是更深层次的研究型 Agent,例如 OpenAI 的 Deep Research,也是通过创建搜索轨迹(search trajectory),在各种关键词和上下文中进行搜索,以确定用户在寻找什么。模型还有产生幻觉、迷失方向或返回它们自认为准确结果的倾向,因为它们对该语料库没有心智模型或记忆。这对于不太复杂的系统,或者对于那些拥有数百万份文档且几个词的差异无关紧要的语料库来说,可能是行得通的。

然而,我们的用例没有这种自由。我们需要 Agent 在数千份文档中进行搜索,并依靠语料库提供正确的观点。

我们既不能指望用户拥有完全正确的信息,也不能指望模型拥有完美的记忆或轨迹。我们需要构建一个系统,无论搜索规模如何,都能返回最准确的结果(或结果集)。最后,我们构建的是一个面向消费者的应用程序,因此延迟是一个主要因素。与可以花费数分钟进行的深度研究任务不同,我们只有毫秒级的时间来确保获得结果并继续下一步。

意义 (Meaning)

文档搜索的结构化程度也较低。我们无法像 Google 索引网页那样构建链接图谱,因为文档之间的内容可能没有明确的关系。我们也无法像 Cursor 这样的代码工具对程序进行 Token 化(tokenize)那样依赖结构信息。

纯语义方法可能会从错误的文件中返回正确的概念,这会混淆模型的视听;而全文搜索可能会完全遗漏更抽象的概念。面对如此多的模糊性,一个支持混合搜索(hybrid search)的系统至关重要。

问题 (Problems)

最后,以下是我们必须满足的一些约束条件:

因此,我们需要一个能够在毫秒级内返回最准确结果的搜索系统,该系统需要跨越碎片化的多租户数据,且不依赖结构、情感语言或其他嘈杂信号。

问题解决 (Problem Solving)

存储 (Storage)

在决定解决方案时,我并没有立即选择专用的向量数据库。以下是我必须解决的问题:

  1. 元数据作为一等公民:我们需要最优化的方式来缩小搜索空间,无论是跨分区还是跨列。SQL 为我们提供了高度优化且原生的数据库级过滤操作,这使得我们可以在进行任何向量计算之前就进行过滤,这有助于我们超越专用向量数据库所能提供的优化(后者通常需要通过后处理或不太优化的方法来实现)。
  2. 多租户需求:我们需要能够快速地跨不同实体集进行搜索,有时是在同一次搜索中。为了保持比并行查询更快的速度,我们希望搜索空间在搜索完成之前就能进行扩展或收缩。
  3. 混合搜索:通过向量捕捉文档的细微差别,同时通过内容或关键字匹配捕捉文档内容。这意味着要同时进行全文搜索、模糊排名和嵌入(embeddings),并根据我们的参数原生返回具有正确排名的结果。
  4. 更新和缓存:文档在处理过程中会发生变化。我们需要一种方法来维护缓存,快速使其失效,并在无需担心数据存储位置的情况下更新源数据。此外,我们希望优化 UPSERT(更新插入)和 DELETE 操作,而不是通过其他数据库 API 来跟踪我们的数据。

我想在此说明,对于解决通用搜索问题,pgvector 可能不是终极解决方案,有许多伟大的公司为此做了出色的工作,我们也喜欢他们的产品!Index 旨在解决一个非常具体的问题,即让我们的 Agent 系统更容易进行搜索,这就是我们要做出此处概述的决策的原因 :)

全球化 (Globalization)

我在问题空间中提到的两个约束条件是速度和全球延迟。为此,@CloudflareDev 成了我们最好的解决方案。

通过构建在 Workers Platform 上,我们的服务自动获得了规模化优化并分布在全球各地。我们还获得了一套很棒的开发原语(developer primitives),这使得实现我们的缓存层和文档处理工作流变得更加容易,我将在开发概述中详细介绍。

此外,对于文本嵌入和重排序(reranking),我们可以使用 Workers AI 工具来访问全球网络上的多语言模型推理。我们不仅不需要依赖第一方服务,还享有构建在开源之上的好处。这对我们系统未来的可扩展性有巨大的好处,因为我们正致力于开发和蒸馏我们自己的嵌入模型。

设计处理流程 (Designing Processing)

到目前为止,系统的关键部分已经确定:存储层和代码层。现在,我们必须将它们结合起来,并遵守 @CloudflareDev Workers 严格的内存限制。为此,我在对象存储之上设计了工作流。

首先是用 Workers 构建一个存储系统,支持通过 Cloudflare R2 的 S3 兼容 API 进行分片上传和预签名上传。这是我们存储文档和中间产物的地方,允许在我们的持久化工作流中保持内存效率,并让无状态容器创建一个“处理池”。这些对象 URL 在 Worker 之间传递,并用于整个摄取过程,从而保持步骤之间所需的内存量处于低水平。

摄取管道(ingestion pipeline)有三个职责:存储文档、检测变更和生成可搜索的切片(chunks)。

当我们的存储服务收到特定路径的上传时,我们知道这已经排队了一个文档,它可以通过队列触发我们的摄取 Worker(这部分系统是解耦的,因此摄取 Worker 可以由任何格式符合预期的对象触发)。

该 Worker 通过将其放入事件队列来运行,该队列可以控制我们通过工作流处理文档的速度。最后,工作流在无状态容器上运行以下步骤,以优化计算和流程的持久性。

  1. 分块 (Chunking):我们使用递归 (recursive)算法将文档切分成重点突出的部分。选择递归分块是因为其他方法很大程度上取决于它们服务的文档类型,或者会增加系统的延迟。正如链接中的分析提到的,递归分块“[对]语料库的语义内容不敏感”,这有助于我们支持高度非结构化的数据流(讲座、笔记、成绩单等),并确保切片能与摘要(第4步)一起提供相关的上下文。我们最初使用的是 Mastra 的库 (@Mastra),但为了优化包大小并移除依赖项以最小化我们的容器,我将其精简为以下包:https://libraries.io/npm/@abhi-arya1%2Fmastra-minirag。这允许仅运行递归函数。
  2. 分类 (Categorization):由于此文档可能之前已存在于数据库中,我们会匹配现有的切片并基于哈希值进行比较,以检测哪些切片发生了变化(需要重新嵌入),或者可以用新的元数据(即文档摘要、标签、位置索引等)进行更新插入(upsert)。
  3. 嵌入 (Embedding):所有需要嵌入的切片都会计算其值。为了优化推理的速度和成本,我一直在后台慢慢蒸馏嵌入模型以减少层数,并希望尽快迁移到这些模型(特别是 Cloudflare 最近收购了 Replicate 之后)。不过,目前这部分如前所述运行在 Cloudflare Workers AI 上。
  4. 摘要 (Summarization):为了让 Agent 在搜索文档时保持脚踏实地(grounded),我们生成摘要。根据文档的大小,这些摘要要么是从文档内容中采样的,要么是由具有高 Token 速度的 LLM 生成的。这让 Agent 可以获得关于最相关切片及其所属文档的上下文,从而指导它们的搜索轨迹(特别是在多步搜索中)。
  5. 保存 (Saving):我们处理的所有切片都会被更新插入(upserted)到数据库中。
  6. 删除 (Deletion):随着新文档的出现,所有现在已过时的切片(特别是对于文档更新)都将被删除,工作流完成。

以下是整个处理工作流的图表:

图像

处理工作流图表

现在,我们需要回到如何存储切片的问题上。由于 Opennote 使用 Supabase 进行用户级存储,最初这似乎是一个快速的修复方案。然而,我很快就遇到了速度瓶颈,特别是在评估中运行数千个并行查询和索引作业时。这让我看到了 @PlanetScale 的这篇文章,促使我将 MVP 实现迁移到他们的 Metal 基础设施上。这使我们的 P99 延迟(从往返时间到搜索测量)减少了一半以上,从 475ms 降至 209ms,同时也降低了所有其他任务的平均时间。PlanetScale 是 Index 能够运行的关键原因,支撑着服务的核心。

除此之外,通过跨实体对数据库进行分区以及构建其他较小的改进(例如预计算全文向量),显著提高了基准测试中多文档查询的速度。

现在,切片已经在数据库中了,我们只需要找到它们。搜索系统的工作原理如下:

  1. 查询解析 (Query Parsing):我们接收来自 Agent 的查询以及任何参数。除此之外还有很多参数,有些供 Agent 使用,有些则不是。例如,Agent 查询可能会过滤我们想要查找的来源(即期刊、PDF、网页),或者过滤诸如“收藏”之类的标签。在应用程序代码中,我们可以调整全文搜索和向量搜索的阈值、HNSW 索引参数等。任何 Agent 参数在发送到数据库之前都会经过清理和验证。
  2. 过滤 (Filtering):现在,搜索的其余部分在我们的 Postgres 数据库中运行。我们将搜索空间切分,以通过实体边界、过滤后的文档 ID、来源、标签、时间戳和显式排除项,使搜索空间尽可能小。
  3. 搜索 (Search):我们现在根据阈值在过滤后的候选集上运行全文匹配和语义匹配查询。Postgres 支持的功能(即 pgvector、pg_tgrm 和 tsvector 函数)协同工作,为我们提供最佳的结果集。
  4. 归一化 (Normalization):这些搜索使用不同的排名方法和不兼容的范围,因此我们将最终的“分数”归一化为 0->1。这使我们可以根据我们在查询时设置的“alpha”值进行合并。这个值让我们调整对向量搜索的侧重程度(即 1 是纯向量,而 0 仅是全文)。这给出了结果集的最终排名。
  5. 其他步骤:在返回排名的 Top-K 结果以及切片元数据之前,数据库中还会完成各种其他步骤,例如可选的去重(针对唯一文档而不仅仅是切片)。
  6. 返回给 Worker 后,如果启用了重排序,这些切片可以被选择性地重排序,或者写入缓存(稍后会详细介绍),最后交付给用户。

搜索通过 Cloudflare Hyperdrive 绑定运行,以优化端到端搜索的最快时间。下图概述了搜索流程:

图像

搜索流程图

缓存 (Caching)

与任何优秀的搜索系统一样,为了在大规模下进行优化,需要某种形式的查询缓存路径。

然而,针对我们用例的缓存有一些细微差别。正如前面关于轨迹的讨论,我们希望 Agent 能够在一个任务中发送许多查询,理想情况下会有重叠,我们不希望这些查询阻塞数据库。同时,LLM 在处理更高级的任务时具有高度的不确定性,这会导致重复搜索,这同样会增加计算量。所有这些都指向了缓存的需求。

但是,在一个数据不断变化的系统中,缓存是很难的,Index 遇到了我们需要解决的三个关键问题:

Put(放入)操作基于查询的哈希值和某些特定参数(如实体、过滤器等)将搜索结果放入 KV 存储中。同时,一个 Vectorize 存储维护查询文本到 KV 存储结果的语义映射,并带有元数据过滤以确保查询不会跨实体重叠。

当从缓存 Get(获取)时,首先,我们查看 KV 中是否有完整的查询哈希(即重复搜索),如果有则返回结果。如果未命中,我们可以检查语义缓存,查找同一实体下相似度在一定阈值内的查询。如果这也未命中,则执行上述搜索管道。所有“get”操作都使用实体 ID 作为主要过滤器,确保结果没有重叠。

最后,主要问题变成了失效(invalidation)。如前所述,文档是不断变化的,我们的系统允许更新它们。在这种情况下,我们需要使包含过时切片的缓存存储失效。我们通过维护一个反向映射 (reverse map) 来做到这一点。

我们不仅维护搜索到切片的映射(如 KV 存储中的那样),还维护一个切片到其出现的搜索哈希的 KV 存储映射。如果一个切片已过时并将被删除,包含它的所有缓存结果都将在直接查找和语义查找中失效。这个反向索引确保了整个管道的准确性,并保持我们的数据在搜索中保持新鲜,无论用户的知识库有多活跃。

下图概述了触发缓存的每个操作。

图像

缓存操作图表

有了这些,再加上针对生产环境中 Index 预期严苛程度的强大错误处理和重试方法,我们就拥有了整个搜索系统的雏形。

结果 (Results)

系统完成后,现在是验证的时候了。这些评估旨在证明 Index 在不牺牲搜索质量的同时,能够始终如一地满足我们所寻求的约束条件和架构要求。

我们将 Index 与 ChromaDB 进行了测试,使用了 10,000 个 MSMARCO 对,进行了 100 次搜索,每次搜索 K=10 个结果,测量了以下指标,并解释了它们为何重要:

以下是评估的图表,使用了 BGE-M3 嵌入模型(用于多语言支持),K=10:

图像

Index 与 ChromaDB 之间的检索指标比较图表

Index 在所有指标上都保持了与 ChromaDB 相当的质量,同时支持混合搜索、多租户隔离(和动态搜索空间)以及每次查询低于 300ms 的 P99 延迟。

澄清一下,Index 的目标不是要取代专门的搜索系统或彻底击败它们,也不是为了优化出最高的数字。Chroma 打造了一款我们非常喜欢的出色产品,但我们现在拥有了一些更适合我们用例的东西,并能以我们需要的方式支持各种查询,适应我们的使用模式。

运行这些评估也证实了 Index 达到了其设计目标:快速的多租户搜索、为 Agent 系统提供可靠的切片选择,以及在不同文档结构下可预测的行为,同时保持与专用语义搜索引擎相当的检索质量。我们还使用较小的合成数据集进行了网格搜索,以找到最佳的搜索参数集,这些数据集带有模拟 Opennote 真实用例的模拟实体。

Index 已经在生产环境中为 Opennote Communities 提供支持,并将成为平台知识层的基础,处理真实的查询使用模式、并发高性能搜索和不断变化的语料库,同时提供我们所需的速度和准确性。

展望未来 (Looking Ahead)

Index 目前擅长于对无状态知识(期刊、成绩单、包含孤立事实的文档)进行检索,无论是对用户还是 AI 而言。但切片只是事实,我们已经拥有了链接它们的基础设施。

以下是我们从检索迈向理解的后续步骤:

构建在 SQL 之上为这种演进提供了基础。当用户继续与他们的知识互动时,Index 将提供端到端的用户理解,不仅捕捉他们知道什么和谈论过什么,还捕捉这些想法如何连接和演变,最终提供语料库的心智模型,并允许任何在该语料库上搜索的 Agent 访问更多可调参数。

总结 (Wrapping Up)

构建 Index 是一个端到端的优化项目,涵盖存储、管道、嵌入模型、API、缓存行为、SDK 设计和开发者体验。它让我沉迷于每一个细节,无论是深夜阅读 Cloudflare 文档以研究 Workers 和我们的操作约束,还是需要从第一性原理(first-principles)而非现有抽象来看待搜索,并决定许多决策之间的权衡。

搜索仍然是一个高度开放的问题,特别是现在的标准是 Agent 可以随时获取信息,无需人工干预(human-in-the-loop)。接入 AI 的系统需要高度可配置的行为和属性,这些往往不是传统的。围绕这些约束进行构建,不仅是一个基础设施项目,也是一个产品开发问题,这让我感到非常有成就感,并让我学到了很多我一直感兴趣的东西,我也渴望继续深入研究。

我希望这篇总结能帮助任何致力于解决类似问题的人思考信息系统的设计过程,如果你正在构建类似的东西或者对我在这里写的任何内容有想法,我很乐意听到你的声音。