原文地址:https://substack.com/@engineerscodex/p-163255622
最近达到了三亿美元 ARR 的 AI 集成开发环境(IDE)—— 备受欢迎的 Cursor,是使用 Merkle 树来快速索引代码的。接下来,我们要看看这种方法具体如何实现。
什么是 Merkle 树
Merkle 树创建了一个层级结构:每个叶节点标记有数据块的加密哈希值,每个非叶节点标记有其子节点标签的加密哈希值;通过比较哈希值可以高效检测任何层级的变化。
将它们视为数据的指纹系统:
- 每段数据(如文件)获得唯一的指纹(哈希值)
- 成对的指纹被组合并给予新的指纹
- 这一过程持续进行至得到一个主指纹(根哈希)
根哈希汇总了包含在各个部分中的所有数据,作为对整个数据集的加密承诺。这种方法的优点是,任何单个数据发生的变化,都将改变其上方的所有指纹,最终改变根哈希。
Cursor 如何使用 Merkle 树进行代码库索引
根据 Cursor 创始人的帖子和安全文档,Cursor 将 Merkle 树作为其代码库索引功能的核心组件。以下是其工作原理:
步骤 1:代码分块和处理
进行处理前,Cursor 先在本地对代码库文件分块,将代码分割成语义上有意义的片段。
步骤 2:Merkle 树构建和同步
启用代码库索引时,Cursor 会扫描在编辑器中打开的文件夹,计算所有有效文件哈希值的 Merkle 树并将其与 Cursor 的服务器同步(这一步在 Cursor 安全文档中有展开描述)。
步骤 3:生成嵌入向量
将代码块发送到 Cursor 服务器后,使用 OpenAI 的嵌入 API 或自定义嵌入模型创建嵌入向量。这些向量表示捕捉了代码块的语义含义。
步骤 4:存储和索引
嵌入向量及元数据(如起始/结束行号和文件路径)存储在远程向量数据库(Turbopuffer)。为了保持隐私、同时仍能进行基于路径的过滤,Cursor 为每个向量存储一个混淆后的相对文件路径。据 Cursor 创始人所说,请求结束后,代码就不再存储在 Cursor 的数据库中。
步骤 5:使用 Merkle 树进行定期更新
每十分钟,Cursor 会检查哈希值不匹配的情况,使用 Merkle 树识别哪些文件发生了变化。Cursor 安全文档中提到,只需上传已更改的文件、大大减少带宽使用,是 Merkle 树结构最具价值的地方:实现高效的增量更新。
代码分块策略
代码库索引的有效性很大程度上取决于代码如何分块。详细的分块方法可以参考这篇「构建类似 Cursor 的代码库功能」博客:
简单的方法按字符、单词或行分割代码,但它们往往会错过语义边界,导致嵌入质量降低。
- 可以基于固定的标记计数分割代码,但这样可能在中途切断函数或类等代码块。
- 更有效的方法是使用理解代码结构的智能分割器,例如使用高级分隔符(如类和函数定义)在适当语义边界处分割的递归文本分割器。
- 还有一个优雅的解决方案:基于代码的抽象语法树(AST)结构来分割代码。通过深度优先遍历 AST,它将代码分割成适合标记限制的子树。为避免创建过多小块,只要保持在标记限制之内,就会将兄弟节点合并为更大的块。可以使用 tree-sitter 等工具进行 AST 解析,支持多种编程语言。
嵌入向量在推理时的应用
Cursor 如何创建和存储代码嵌入后,它们实际上是如何使用的?下面就将解释这些嵌入在正常使用时的实际应用。
语义搜索和上下文搜索
使用 Cursor 的 AI 功能(如使用 @Codebase 或 ⌘ Enter 提问关于代码库的问题)时,会发生以下过程:
- 查询嵌入:Cursor 为问题或正在处理的代码上下文计算一个嵌入向量。
- 向量相似度搜索:这个查询嵌入被发送到 Turbopuffer(Cursor 的向量数据库),它执行最近邻搜索,找到语义上与该查询相似的代码块。
- 本地文件访问:Cursor 客户端接收结果,其中包括混淆的文件路径和最相关代码块的行范围。实际的代码内容仍然在用户的机器上,并在本地检索。
- 上下文组装:客户端从本地文件中读取这些相关的代码块,将它们作为上下文发送到服务器,供 LLM 与所给问题一起处理。
- 知情响应:LLM 现在拥有来自你代码库的必要上下文,可以对你的问题提供更明智、更相关的回应,或生成适当的代码补全。
这种基于嵌入的检索允许:
- 上下文代码生成:在编写新代码时,Cursor 可以参考你现有代码库中的类似实现,保持一致的模式和风格。
- 代码库问答:你可以提出关于代码库的问题,并获得基于你实际代码的答案,而非通用回答。
- 智能代码补全:代码补全可以通过了解你项目的特定约定和模式来增强。
- 智能重构:在重构代码时,系统可以识别代码库中可能需要类似更改的所有相关部分。
Cursor 为什么使用 Merkle 树
关于这个问题,许多细节都与安全相关,可以在 Cursor 安全文档中找到。
1. 高效的增量更新
通过使用 Merkle 树,Cursor 可以快速识别自上次同步以来哪些文件发生了变化。它只需上传已修改的特定文件,而不是重新上传整个代码库。这一点对于大型代码库尤为重要,因为重新索引所有内容在带宽和处理时间方面成本太高。
2. 数据完整性验证
Merkle 树结构使 Cursor 能够有效验证被索引的文件与存储在服务器上的文件是否匹配。传输过程中,利用层级哈希结构,可以轻松检测任何不一致或损坏的数据。
3. 优化缓存
Cursor 将嵌入存储在以块的哈希为索引的缓存中,确保第二次索引相同代码库会快得多。这对于多个开发者共用代码库的团队非常有利。
4. 保护隐私的索引
为了保护文件路径中的敏感信息,Cursor 通过按 ‘/’ 和 ‘.’ 字符分割路径并用存储在客户端的密钥加密每个段来实现路径混淆。这仍会泄露一些关于目录层级的信息,但至少隐藏了大多数敏感细节。
5. Git 历史集成
在 Git 仓库中启用代码库索引时,Cursor 也会索引 Git 历史。它存储提交 SHA、父信息和混淆的文件名。为了使同一 Git 仓库和同一团队的用户能够共享数据结构,用于混淆文件名的密钥是从最近提交内容的哈希中派生的。
握手过程
Merkle 树实现的一个关键,是同步中的握手过程。Cursor 应用程序的日志显示,初始化代码库索引时,Cursor 创建一个 merkle 客户端并与服务器执行「启动握手」:将本地计算所得 Merkle 树的根哈希发送到服务器(如 GitHub Issue #2209 和 Issue #981)。
握手过程使服务器得以确定代码库需要同步的部分。根据握手日志,Cursor 计算代码库的初始哈希,并将其发送到服务器进行验证(如 GitHub Issue #2209)。
技术实现挑战
Merkle 树方法有许多优势,但也面临实现挑战。
Cursor 的索引功能经常承受重负荷,导致许多请求失败,文件需要多次上传才能完全索引,以至于 repo42.cursor.sh
的流量很容易高于预期。
另一个挑战与嵌入安全相关。研究表明,某些情况下可以反转嵌入。虽然当前的攻击通常依赖于访问嵌入模型和处理短字符串,但存在这样一种潜在风险:获得 Cursor 向量数据库访问权的对手可能从存储的嵌入中提取关于索引代码库的信息。