安全地为大型代码库建立索引

语义搜索是提升智能体性能的最重要驱动因素之一。在我们最近的评估中,它平均将响应准确率提升了 12.5%,生成的代码修改更有可能在代码库中被保留,并整体提高了请求满意度。
为了支持语义搜索,Cursor 会在你打开项目时为你的代码库构建一个可搜索的索引。对于小型项目,这几乎是瞬间完成的。但如果采用朴素方式为包含数万文件的大型仓库建立索引,处理可能需要数小时,而且在至少 80% 的工作完成之前,语义搜索都无法使用。
我们尝试基于一个简单的观察来加速索引:大多数团队实际上都在使用几乎完全相同的代码库副本。事实上,在同一组织内,不同用户之间的同一代码库克隆版本,相似度平均达到 92%。
这意味着,当有人加入团队或更换电脑时,我们无需每次都从头重建索引,而是可以安全地复用队友已有的索引。对于最大型的仓库,这能将首次查询的等待时间从数小时缩短到几秒。
构建第一个索引
Cursor 使用 Merkle tree 来构建它对代码库的初始视图,这使它可以在不重新处理所有内容的情况下,精确检测哪些文件和目录发生了变化。Merkle tree 中包含每个文件的密码学哈希值,以及基于其子节点哈希值计算得到的每个文件夹的哈希。

在客户端进行的小幅编辑,只会改变被编辑文件本身的哈希值,以及从该文件所在目录一路到代码库根目录的各级父目录哈希值。Cursor 会把这些哈希与服务器上的版本进行比较,以精确找出两棵 Merkle tree 分歧的位置。哈希不同的条目会被同步;哈希相同的条目会被跳过。客户端缺失的条目会从服务器上删除,服务器缺失的条目会被添加。同步过程绝不会修改客户端上的文件。
Merkle tree 的方法显著减少了每次同步需要传输的数据量。在一个包含五万个文件的工作区中,仅文件名和 SHA-256 哈希就大约有 3.2 MB。如果没有这棵树,你每次更新都需要传输这些数据。有了这棵树,Cursor 只会遍历哈希不同的分支。
当文件发生变化时,Cursor 会把它拆分成语法块。这些块会被转换成用于语义搜索的 embeddings(嵌入向量)。创建 embeddings 是开销最大的步骤,这也是 Cursor 在后台异步执行它的原因。
大多数编辑会让大部分块保持不变。Cursor 按块内容缓存 embeddings。未变化的块会命中缓存,代理在推理时的响应无需再次付出这部分开销,仍能保持快速。最终生成的索引更新迅速、维护轻量。
寻找可复用的最佳索引
上面的索引流程会在代码库首次接入 Cursor 时上传每一个文件。但在同一组织内,新用户不需要再完整走一遍这个流程。
当新用户加入时,客户端会为这个新代码库计算一棵 Merkle 树,并从树中导出一个称为相似度哈希(simhash)的值。这个值相当于对代码库中文件内容哈希的整体摘要。
客户端会把 simhash 上传到服务器。服务器再把它当作一个向量,在由同一团队(或同一用户)下 Cursor 中所有其他索引的 simhash 组成的向量数据库中进行检索。对于向量数据库返回的每个结果,我们会检查它与客户端相似度哈希的相似度是否高于某个阈值。如果是,就用该索引作为新代码库的初始索引。
这个拷贝过程在后台进行。与此同时,客户端就可以针对正在被拷贝的原始索引发起新的语义搜索,从而为客户端提供极快的首次查询等待时间。
但这只有在两个约束同时满足时才可行:结果需要能反映用户本地的代码库,即使它和被拷贝的索引有所不同;而且客户端永远不能看到其本地并不存在的代码的搜索结果。

访问证明
为了确保文件不会在代码库副本之间泄露,我们复用了默克尔树的加密属性。
树中的每个节点都是其下方内容的加密哈希。只有在你实际拥有该文件时,才能计算出这个哈希。当一个 workspace 基于复制的索引启动时,客户端会连同相似性哈希一起上传其完整的默克尔树。这样就会为代码库中每个加密路径关联一个哈希值。
服务器将这棵树存储为一组内容证明。在搜索过程中,服务器通过将这些哈希与客户端的树进行比对来过滤结果。如果客户端无法证明它拥有某个文件,对应结果就会被丢弃。
这使得客户端可以立即发起查询,并且只看到与该复制索引共享代码的结果。后台同步会对剩余差异进行对账与合并。一旦客户端和服务器的默克尔树根相匹配,服务器就会删除这些内容证明,后续查询将针对已完全同步的索引运行。
更快速的上手体验
复用团队成员的索引可以缩短各种规模代码仓库的初始设置时间,而且仓库越大,效果越显著:
-
对于中位数仓库,首次查询时间从 7.87 秒降至 525 毫秒
-
在第 90 百分位上,时间从 2.82 分钟降至 1.87 秒
-
在第 99 百分位上,时间从 4.03 小时降至 21 秒。
这些改进去除了主要的重复工作来源,让 Cursor 能在几秒内而不是几小时内理解甚至非常庞大的代码库。