Cursor 如何索引你的代码库?

2
分类技术博客
作者Manthan Gupta
来源跳转
发表时间

内容

每次你问 Cursor“我们在哪里处理认证?”时,它都能在不到一秒内把你带到 5 万文件单体仓库里的正确文件,这背后其实发生了很有意思的事情。这不是魔法,而是 Merkle 树、三元组索引、基于 AST 的分块、专门训练的嵌入模型,以及一个向量数据库(turbopuffer)的优雅组合;后者在 8000 万个命名空间中存储着超过一万亿个向量。这篇博客很特别,因为我非常喜欢 Cursor 和 Turbopuffer,所以我想搞清楚它们到底是怎么运作的。让我们开始吧。

双索引策略

大多数人会以为 Cursor 只有一个索引:一个存放代码嵌入的向量库。但 Cursor 实际维护着两种本质上不同、用途也不同的索引:

  • 用于自然语言查询的语义(向量)索引,例如“我们在哪里处理支付重试?”、“展示一下数据库连接逻辑”
  • 用于正则搜索的三元组风格(倒排)索引,类似 grep 那样做结构化模式匹配,但没有 O(n*文件数) 的成本(生产版本使用的是稀疏 n-gram,而不是简单的三元组,后面会详细说)

这两者并不能互相替代。语义索引擅长概念检索,但无法告诉你“找出所有以原始字符串字面量调用 db.execute 的地方”。正则索引则很精确,但完全不理解语义。Cursor 的 agent 框架同时使用两者,而这种组合正是上下文质量足够好、能够写出真正能被保留在代码库里的代码的原因。

Cursor 自己的研究显示,在 grep 之上叠加语义搜索,平均能将 agent 准确率提升 12.5%(不同模型提升幅度从 6.5% 到 23.5% 不等),在大型代码库中将代码保留率提高 2.6%,并将令人不满意的后续追问减少 2.2%。

构建语义索引

当你打开一个项目时,Cursor 会开始构建语义索引。对于一个新的代码库来说,这意味着读取每个文件,把它拆成块,为每个块生成嵌入,然后把这些嵌入上传到向量数据库。下面来看看这些步骤分别具体做了什么。

使用 Tree-Sitter 进行分块

Cursor 将每个文件拆分成“语法块(syntactic chunks)”,而不是固定窗口。它会先把代码解析成抽象语法树(AST),再沿着 AST 边界进行分块,例如函数、类、方法和代码块。(跨数十种语言语法树的标准工具是 tree-sitter,这里的示例也使用它。)函数是代码中天然的语义单元,因此也会成为索引中的天然单元。通常的做法是:把较小的同级 AST 节点合并,直到达到 token 上限,而像函数或类这样的完整语义单元则保持整体不被切碎,这样一个块的嵌入才能捕捉到有意义的内容,而不是任意字符窗口。

下面是 AST 分块的简化版示例:

import tree_sitter_python as tspython
from tree_sitter import Language, Parser

PY_LANGUAGE = Language(tspython.language())
parser = Parser(PY_LANGUAGE)

MAX_CHUNK_BYTES = 1500

def chunk_file(source_code: bytes) -> list[str]:
    tree = parser.parse(source_code)
    chunks = []
    
    for node in tree.root_node.children:
        # Top-level functions and classes become individual chunks
        if node.type in ("function_definition", "class_definition"):
            chunk_text = source_code[node.start_byte:node.end_byte].decode()
            chunks.append(chunk_text)
        # Small sibling nodes (imports, constants) get merged
        else:
            if chunks and len(chunks[-1]) + len(source_code[node.start_byte:node.end_byte]) < MAX_CHUNK_BYTES:
                chunks[-1] += "\n" + source_code[node.start_byte:node.end_byte].decode()
            else:
                chunks.append(source_code[node.start_byte:node.end_byte].decode())
    
    return chunks

这只是对真实实现的简化。真实实现会复杂得多,需要处理嵌套结构、多种语言,以及几十种语言语法树的边界情况,但核心思路是一样的。

基于 Agent 轨迹训练的定制嵌入模型

有意思的是,Cursor 并没有直接使用 text-embedding-ada-002text-embedding-3-small 这类现成的嵌入模型。他们明确表示,自己训练了专有嵌入模型,而训练信号来自 agent 会话本身。

巧妙之处在于训练标签的来源。Cursor 直说:agent 会话就是训练数据。当 agent 处理任务时,它会在找到正确代码之前进行多次搜索并打开多个文件;通过分析这些轨迹,你就能事后看出,哪些内容本应更早被检索出来。他们会把每条轨迹交给一个 LLM,由它对在每一步最有帮助的内容进行排序,然后训练嵌入模型,使其相似度分数与这些排序对齐。这就是他们的说法,我也认为应当把它视为事实依据。

我从字里行间读到的是:一个会话本质上就是一个自标注数据集。agent 最终成功完成的修改,以及它反复回访的那些文件,恰恰是 LLM 评估器会依赖的那类“事后信号”,用来判断在每一步中什么“本该浮现”。而“让相似度分数与这些排序对齐”通常最终都会落实为某种对比学习式目标:把查询向量拉向那些最终真正有用的代码块,同时把它推离那些只是看起来相似的块。这样做的最终效果就是他们明确说出来的那一点:模型不再优化“这两段代码是否相似?”,而是开始优化“给定这个任务和上下文,哪个代码块真的能帮到 agent”。

这就形成了一个以 agent 实际使用代码的方式为基础的反馈闭环,而不是泛泛的代码相似性。这和利用人类反馈偏好数据的思路是一样的,只不过这里用在了检索上。

Turbopuffer:每个代码库一个命名空间

这些嵌入最终存入 Turbopuffer,这是一个对象存储架构支持无限命名空间的向量数据库,非常适合 Cursor 这种“每个代码库一个命名空间”的用法。每个代码库都有自己的命名空间。活跃命名空间保存在内存/NVMe 中;不活跃的则退到对象存储里。当某个查询命中一个不活跃的命名空间时,它会按需被唤醒。

Cursor 正在跨 8000 万个命名空间运行着超过 1 万亿个向量。他们之前的向量数据库要求人工把命名空间按负载分箱到各个服务器上,这一直是个运维噩梦。Turbopuffer 的无服务器架构彻底消除了这个问题,并将成本降低了 20 倍。它的 API 很直接:

import turbopuffer

tpuf = turbopuffer.Turbopuffer(region="gcp-us-central1")

# One namespace per codebase, keyed by a hash of the repo path
ns = tpuf.namespace(f"codebase-{repo_hash}")

# Upsert embeddings for new or changed chunks (row-based format)
ns.write(
    upsert_rows=[
        {"id": chunk.id, "vector": chunk.embedding, "file_path": chunk.path}
        for chunk in chunks
    ],
    distance_metric="cosine_distance",
    schema={"file_path": {"type": "string", "glob": True}},
)

# Query at search time
results = ns.query(
    rank_by=("vector", "ANN", query_embedding),
    top_k=20,
    filters=("file_path", "Glob", "src/**/*.py"),
    include_attributes=["file_path"],
)

使用 Merkle 树高效同步

嵌入计算是昂贵的。在大型代码库里,每次改动都从头为所有块重新生成嵌入,速度会慢得不可接受。Cursor 使用 Merkle 树 精确判断哪些块需要重新嵌入。

Merkle 树会为每个文件分配一个加密哈希(SHA-256),然后再根据子节点的哈希为每个目录生成哈希。Git 的对象模型也是同样的思想:Git 按内容哈希来键控对象,并以相同方式把它们串联起来(不过 Git 用的是 SHA-1 而不是 SHA-256),因此任何一个文件的变化都会把哈希沿着所有父目录一路传播到根节点。

当文件发生变化时,Cursor 会遍历 Merkle 树,准确找出客户端与服务器之间哪些分支出现了分歧。只有变更过的文件才需要重新分块和重新嵌入。流程大致如下:

import hashlib
from pathlib import Path
from dataclasses import dataclass

@dataclass
class MerkleNode:
    path: str
    hash: str
    children: list["MerkleNode"]
    is_file: bool

def hash_file(path: Path) -> str:
    content = path.read_bytes()
    return hashlib.sha256(content).hexdigest()

def build_merkle_tree(root: Path) -> MerkleNode:
    if root.is_file():
        return MerkleNode(str(root), hash_file(root), [], is_file=True)
    
    children = [build_merkle_tree(child) for child in sorted(root.iterdir())]
    
    # Directory hash is SHA-256 of all children's hashes concatenated
    combined = "".join(child.hash for child in children)
    dir_hash = hashlib.sha256(combined.encode()).hexdigest()
    
    return MerkleNode(str(root), dir_hash, children, is_file=False)

def find_changed_files(client_tree: MerkleNode, server_tree: MerkleNode) -> list[str]:
    """Walk both trees simultaneously, only descending into differing branches."""
    if client_tree.hash == server_tree.hash:
        return []  # Entire subtree is unchanged, skip it
    
    if client_tree.is_file:
        return [client_tree.path]
    
    # Build lookup for server children
    server_children = {child.path: child for child in server_tree.children}
    changed = []
    
    for client_child in client_tree.children:
        if client_child.path not in server_children:
            # New File
            changed.append(client_child.path)
        else:
            changed.extend(
                find_changed_files(client_child, server_children[client_child.path])
            )
    
    return changed

效率提升非常可观。在一个 5 万文件的工作区里,仅文件名和 SHA-256 哈希加起来就大约有 3.2 MB。如果没有 Merkle 树,你每次更新都得传输这些数据来检查哪些内容变化了。有了 Merkle 树,你只需要沿着哈希不同的分支向下走;在一次普通的编码会话后,通常只会涉及寥寥几个文件。

复用队友的索引

下面这部分是 Cursor 索引管线里我认为真正聪明、也确实提升性能的地方。在大型单体仓库中,从零开始建索引要花好几个小时。但团队里的大多数工程师,工作时往往使用同一个代码库的近似副本;在一个组织内部,这些克隆版代码库的平均相似度可达 92%。对同一份代码一遍又一遍地重新生成嵌入,本质上就是浪费算力和存储。

当新用户打开一个代码库时,Cursor 会计算 Merkle 树并生成一个 simhash——一个用于概括文件内容哈希分布的单值,思路上类似局部敏感哈希(LSH)。客户端将这个 simhash 发送到服务器,服务器会在整个组织已有的 simhash 向量数据库中搜索,找到最相似的现有索引。

如果相似度超过阈值,Cursor 会通过 Turbopuffer 的 copy_from_namespace 操作,从已有命名空间为新用户的命名空间播种索引,写入成本只需一半。用户可以立刻查询这个复制来的索引,而后台同步会在后续协调差异,并处理相对于被复制代码库的本地改动。结果如下:

  • 中位数代码库:首次查询时间从 7.87 秒降至 525 毫秒
  • 第 90 百分位:2.82 分钟 -> 1.87 秒
  • 第 99 百分位:4.03 小时 -> 21 秒

但这也带来了一个安全问题:用户 A 的索引里可能包含用户 B 不该看到的代码。如何在不突破信任边界的前提下复用索引?

使用 Merkle 证明验证访问权限

这个问题的解决方案很优雅。由于 Merkle 树中的每个节点都是其下方内容的加密哈希,因此只有当你实际拥有这些文件时,才能计算出对应哈希。当用户 B 以复制来的索引开始工作时,他们的客户端会上传完整的 Merkle 树。服务器会把这棵树存成一组内容证明,用于证明索引中每个文件路径对应的哈希,说明客户端确实持有这些文件。

当用户 B 进行语义搜索时,系统会通过检查返回块的文件路径是否存在于用户 B 的内容证明中来过滤结果。如果用户 B 无法证明自己拥有某个文件(因为那个哈希不在他们的 Merkle 树里),那么该结果就会被丢弃。这样他们只能看到本地机器上实际包含的代码的搜索结果。

这样就能在没有任何服务端文件内容检查的情况下,获得共享索引和强安全保障。服务器仍然会保存服务查询所需的嵌入向量和经过混淆的文件路径,但它永远看不到原始源码。访问决策完全依赖于客户端只有在真的持有该文件时才能生成的哈希。

三元组索引

语义索引负责处理概念性查询,但 agent 也需要精确的模式匹配。问题在于 ripgrep 在小项目里很快,但在大型单体仓库中,Cursor 发现 rg 的调用有时会超过 15 秒。这会让整个 agent 工作流卡住,直到搜索结果返回。

Cursor 所基于的基础,是 三元组索引。这与 Zobel、Moffat 和 Sacks-Davis 在 1993 年描述的方法,以及 Russ Cox 2012 年关于 Google Code Search 的博客文章所推广的方法是一致的。其思路是:对代码库中每一个重叠的 3 字符序列(三元组)预先建立倒排索引。

def extract_trigrams(text: str) -> set[str]:
    """Extract all overlapping 3-character sequences."""
    return {text[i:i+3] for i in range(len(text) - 2)}

def build_trigram_index(files: dict[str, str]) -> dict[str, set[str]]:
    """Build inverted index: trigram → set of file IDs containing it."""
    index = {}
    for file_id, content in files.items():
        for trigram in extract_trigrams(content):
            index.setdefault(trigram, set()).add(file_id)
    return index

在查询时,像 db\.execute\( 这样的正则会被分解成字面量三元组:db.b.e.exexexececucututete(。搜索引擎会交集所有这些三元组的倒排列表,找出候选文件——这只占代码库极小的一部分。然后再用“老办法”把正则真正匹配到这些候选文件上,以确认是否真的命中。

def regex_candidate_files(
    pattern: str,
    index: dict[str, set[str]]
) -> set[str] | None:
    """Find candidate files using trigram index before running regex.

    Returns None when the pattern has no extractable literals, signaling
    the caller to fall back to a full scan.
    """
    # Extract literal strings from the regex pattern
    # (simplified — real implementation parses the full regex AST)
    literal_parts = extract_literals_from_regex(pattern)
    
    if not literal_parts:
        return None  # No trigrams extractable, must scan everything
    
    # Get trigrams from all literal parts
    trigrams = set()
    for literal in literal_parts:
        trigrams.update(extract_trigrams(literal))
    
    # Intersect posting lists: files must contain ALL trigrams
    candidate_files = None
    for trigram in trigrams:
        matching_files = index.get(trigram, set())
        if candidate_files is None:
            candidate_files = matching_files
        else:
            candidate_files &= matching_files  # Intersection
    
    return candidate_files or set()

三元组索引充当过滤器。如果你的正则包含足够多的字面量字符(大多数真实模式都包含),就可以在实际执行正则匹配之前,把候选集合从 5 万个文件缩小到寥寥几个。由于误报率足够低,过滤步骤本身就成了性能的主导因素。

经典三元组索引是 Cursor 最初实现的起点,但他们很快意识到,纯三元组在单体仓库规模下会遇到容量问题:常见三元组会产生极大的倒排列表,加载它们几乎和扫描全部内容一样慢。所以 Cursor 的生产索引在这个思路上做了两项扩展。首先,它使用 稀疏 n-gram:不是对每个固定的 3 字符窗口都建索引,而是使用确定性选择的可变长度 n-gram,这样索引更小,查询也能拆成更少、更有选择性的查找。其次,它为每个倒排条目附加了小型 bloom-filter 掩码,编码三元组后面跟着的字符以及它出现的位置,因此一个以三元组键控的索引可以达到四元组级别的精度,并验证匹配到的三元组是否 वास्तव上是相邻的。下面的心智模型是经典三元组过滤器,而实际上线的系统则是在这个过滤器基础上继续锐化,直到候选集足够小,可以在本地通过内存映射查找进行确认。

还有一个很容易被忽略、但非常重要的架构决策。语义索引部署在 Cursor 的服务器上,但正则索引完全在你的机器上构建和查询。原因是“新鲜度”。过时的嵌入向量在向量空间里仍然大致朝着正确方向,因此语义索引即便滞后于你的编辑,也通常问题不大。但正则索引不能这样:一旦 agent 搜索它刚写下的代码,却发现索引里还没有,那它就会陷入低效的追踪。Cursor 通过将本地索引锚定到当前 Git 提交,并在其上叠加用户和 agent 的改动,保持索引新鲜,这样它就能在每次按键时低成本更新,并在启动时快速加载。

动态上下文发现

还有一层值得理解。agent 并不会把检索到的所有块都直接塞进上下文窗口。Cursor 正在转向他们所谓的 动态上下文发现,把文件作为上下文管理的基本单位。

与其急着把检索到的内容注入提示词,不如把过大的工具响应写入临时文件。agent 会拿到文件路径,并可以根据自己当前的推理状态决定是读取、截断查看,还是 grep 它。这样既能避免大型工具响应导致上下文膨胀,又能确保信息不会因为截断而丢失。

同样的模式也适用于终端输出、MCP 工具响应和聊天历史。agent 可能需要的所有内容都可以以文件的形式访问。agent 不是一次性接收全部内容,而是按需检索真正需要的部分。在一项针对 MCP 工具使用的 A/B 测试中,这将 agent 总 token 消耗减少了 46.9%。我认为这是个非常有意思的方法,也是一种很好的方式,能让上下文窗口保持小巧,并让 agent 专注于当前任务。

总结

把整个索引流水线端到端看一遍,它的工作方式如下:

  • 打开项目时:对代码库中的所有文件构建一棵 Merkle 树
  • 语义索引(服务端),首次构建:使用 tree-sitter 分块,用 Cursor 的定制模型生成嵌入,并上传到 Turbopuffer 中每个代码库对应的命名空间。这个语义索引运行在 Cursor 的服务器上。
  • 语义索引,后续打开:计算 simhash,在组织内查找相似的现有索引,通过 copy_from_namespace 复制最接近的匹配项
  • 语义索引,后台同步:遍历 Merkle 树差异以找出变更文件,只重新嵌入这些文件,并更新命名空间。嵌入允许一定程度的陈旧,因此这是异步完成的。
  • 正则索引(客户端):在本地机器上构建三元组 / 稀疏 n-gram 索引,以当前 Git 提交为基础,并叠加用户和 agent 的改动。与语义索引不同,它会在每次编辑后保持最新,因为一条没命中模型刚写下代码的正则会把 agent 带进死胡同。
  • 查询时:语义查询带着内容证明发送到 Turbopuffer 以做访问控制;正则查询先命中本地索引筛选候选项,再执行真正的正则匹配
  • 在上下文中:检索到的内容以文件形式呈现,供 agent 按需读取,而不是预先塞进上下文

这个架构之所以让我觉得有趣,并不在于某一个单独部件——Merkle 树、三元组索引和基于内容的寻址都已经有几十年历史了。真正精彩的是它们的组合:用 Merkle 树的密码学特性同时做变更检测和访问控制;基于 agent 会话轨迹而不是代码相似性来训练嵌入模型;以及贯穿整个系统的“文件即上下文”抽象。

下次当 Cursor 在半秒内从 5 万个文件里准确找到你脑海中的那个函数时,你现在已经知道它在底层做了什么。

如果你喜欢这篇文章,可以在 TwitterLinkedIn 上找到我。如果你也在这个领域做任何东西,我很想听听——guptaamanthan01[at]gmail[dot]com。

参考资料

评论

(0)
未配置登录方式
暂无评论