你有没有好奇过:Cursor 为什么能在一个几十万行、几万个文件的项目里,很快找到“你说的那段逻辑”?它显然不可能每次都把整个代码库塞进模型上下文,也不只是简单跑一遍 grep。真正有意思的问题是:它到底怎么理解代码、怎么知道哪些文件值得读、又怎么在速度、准确性和权限控制之间做取舍?
这篇文章就拆开了 Cursor 代码库索引背后的关键机制:语义索引、正则索引、AST 分块、自定义 embedding、Merkle 树、Turbopuffer 向量库,以及「文件即上下文」的动态检索方式。对正在用 Cursor、Claude Code、Copilot Workspace,或者自己在做 Coding Agent 的开发者来说,这篇文章值得细读,因为它解释的是 AI 编程工具真正能“看懂项目”的基础设施。
以下为译文。为方便中文读者理解,部分术语和句式做了本地化处理。
每当你问 Cursor「我们在哪里处理身份认证?」,它能在不到一秒内从包含 5 万个文件的 monorepo 里指向正确文件时,底层其实发生了一件很有意思的事。这不是魔法,而是 Merkle 树、trigram 索引、基于 AST 的分块、自研 embedding 模型,以及向量数据库(turbopuffer,在 8000 万个 namespace 中存储超过万亿向量)的优雅组合。这篇博客很特别,因为我是 Cursor 和 Turbopuffer 的忠实粉丝,所以觉得深入理解其底层机制会很有趣。我们开始吧。
双索引策略
大多数人以为 Cursor 只有一个索引——一个存放代码 embedding 的向量库。但实际上,Cursor 维护着两种本质不同的索引,各自承担不同职责:
- 语义(向量)索引:用于自然语言查询,例如「我们在哪里处理支付重试?」「给我看数据库连接逻辑」
- Trigram 风格(倒排)索引:用于正则表达式搜索,类似 grep 那种结构化模式匹配,但避免了 O(n×files) 的成本(生产环境用的是 sparse n-grams,而非普通 trigram,后文详述)
二者不可互换。语义索引擅长概念检索,但无法回答「找出所有用原始字符串字面量调用 db.execute 的地方」。正则索引精确,却对语义完全无感。Cursor 的 agent harness 同时使用两者,正是这种组合让上下文质量足够好,写出的代码才更可能被真正保留在代码库中。
Cursor 自己的研究表明,在 grep 之上叠加语义搜索,平均可将 agent 准确率提高 12.5%(因模型不同,范围在 6.5% 到 23.5%),在大代码库上提高代码保留率 2.6%,并将不满意的后续请求减少 2.2%。
构建语义索引
打开项目时,Cursor 就开始构建语义索引。对于全新代码库,这意味着读取每个文件、切分成块、为每块生成 embedding,并上传到向量数据库。下面逐步说明每个环节实际在做什么。
用 Tree-Sitter 分块
Cursor 将每个文件拆成「语法块」,而非固定窗口。先解析成抽象语法树(AST),再沿 AST 边界切分,例如函数、类、方法和代码块。(跨数十种语言语法,标准工具是 tree-sitter,下文示例也基于它。)函数是代码中天然的意义单元,也就成为索引中的天然单元。典型做法是:在 token 上限内合并较小的兄弟 AST 节点,同时保持函数、类等连贯单元完整,这样每个块的 embedding 捕获的是有意义的内容,而非任意字符窗口。
下面是基于 AST 分块的简化示意:
python
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 轨迹训练的自定义 Embedding 模型
有意思的是,Cursor 并没有直接选用现成的 embedding 模型,例如 text-embedding-ada-002 或 text-embedding-3-small。他们明确表示会训练自己的 embedding 模型,而训练信号来自 agent 会话本身。
巧妙之处在于训练标签的来源。Cursor 直说:agent 会话就是训练数据。当 agent 完成一项任务时,会先多次搜索、打开文件,最后才找到正确代码;回顾这些轨迹,就能看出在对话更早阶段本应检索到什么。他们把每条轨迹交给 LLM,由 LLM 对「每一步最有帮助的内容」排序,再训练 embedding 模型,使其相似度分数与这些排序对齐。这是他们官方描述,也是我倾向于当作事实依据的部分。
我据此推断:一次会话本质上就是自标注数据集。agent 最终成功的修改,以及它反复回到的文件,正是 LLM 评分器用来判断「每一步本该提前检索到什么」的事后信号。而「让相似度与排序对齐」几乎总是落实为某种对比学习目标:把 query 向量拉向真正有用的块,推离仅表面相似的块。最终效果正是他们明确说的:模型不再优化「这两段代码是否相像?」,而是优化「给定任务和上下文,哪一块 chunk 真正帮助 agent」。
这形成了一种反馈闭环,扎根于 agent 实际如何使用代码,而非泛化的代码相似度。与用人类反馈的 preference 数据是同一思路,只是应用在检索上。
Turbopuffer:每个代码库一个 Namespace
Embedding 最终写入 Turbopuffer——一种向量数据库,其对象存储架构与无限 namespace 天然契合 Cursor「每个代码库一个 namespace」的用法。每个代码库独占一个 namespace。活跃 namespace 保留在内存/NVMe;不活跃的则沉降到对象存储。查询命中 inactive namespace 时,按需预热。
Cursor 在 8000 万个 namespace 上运行超过 1 万亿向量。他们之前的向量库需要手动把 namespace bin-pack 到服务器上,运维负担很重。Turbopuffer 的无服务器架构彻底消除了这个问题,并将成本降低约 20 倍。API 很直观:
python
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 树高效同步
Embedding 计算代价很高。在大代码库上,每次改动都从头 embed 所有块会慢到不可用。Cursor 用 Merkle 树精确知道哪些块需要重新 embedding。
Merkle 树为每个文件分配密码学哈希(SHA-256),再基于子节点哈希为每个目录计算哈希。这与 Git 对象模型的思路相同:Git 按内容哈希键控对象并链式组合(用的是 SHA-1 而非 SHA-256),因此任意文件变更都会沿父目录一路传播到根哈希。
文件变更时,Cursor 遍历 Merkle 树,找出客户端与服务端分叉的分支。只有变更文件需要重新分块和 re-embed。流程示意如下:
python
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 索引流水线里真正巧妙的性能设计是这一段。大型 monorepo 从零索引可能要数小时,但团队里多数工程师用的是几乎相同的代码库副本,组织内 clone 平均相似度约 92%。对同一段代码反复 re-embed 是纯浪费算力和存储。
新用户打开代码库时,Cursor 计算 Merkle 树并导出 simhash——一个汇总文件内容哈希分布的单一值,精神上类似于 locality sensitive hash。客户端把 simhash 发给服务端,服务端在组织内所有已有 simhash 的向量库中搜索,找到最相似的现有索引。
若相似度超过阈值,Cursor 用 Turbopuffer 的 copy_from_namespace 从现有索引初始化新用户的 namespace,写入费用打 5 折。用户可立即查询复制来的索引,后台同步再对齐差异及相对复制基线的本地变更。效果如下:
- 中位仓库:首次可查询时间从 7.87 秒降至 525 毫秒
- 90 分位:2.82 分钟 → 1.87 秒
- 99 分位:4.03 小时 → 21 秒
但这带来安全问题:用户 A 的索引可能包含用户 B 不应看到的代码。如何在复用索引的同时不跨越信任边界泄露代码?
用 Merkle 证明访问权限
解决方案很优雅。Merkle 树每个节点都是其下内容的密码学哈希,只有真正持有文件才能算出该哈希。用户 B 从复制索引启动时,客户端上传完整 Merkle 树。服务端将其存为一组内容证明:对索引中每个文件路径,证明客户端拥有对应哈希。
用户 B 做语义搜索时,返回结果会与其内容证明比对文件路径。若用户 B 无法证明拥有某文件(哈希不在其 Merkle 树中),该结果被丢弃。他们只能看到本地机器实际包含的代码的搜索结果。
这样在硬安全保证下共享索引,无需服务端检查原始文件内容。服务端仍存储 serving 查询所需的 embedding 向量和混淆后的文件路径,但从不看到原始源码。访问决策完全依赖客户端只有真正持有文件才能产生的哈希。
Trigram 索引
语义索引处理概念查询,但 agent 也需要精确模式匹配。问题是 ripgrep 对小项目很快,但在大型 monorepo 上 Cursor 观察到 rg 调用有时超过 15 秒,整个 agent 工作流会卡在等待搜索结果上。
Cursor 的基石是 trigram 索引——Zobel、Moffat 和 Sacks-Davis 于 1993 年描述、Russ Cox 2012 年 Google Code Search 博文推广的思路。核心是为代码库中每个重叠的 3 字符序列(trigram)预建倒排索引。
python
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\( 这样的正则会被分解为字面 trigram:db.、b.e、.ex、exe、xec、ecu、cut、ute、te(。搜索引擎对这些 trigram 的 posting list 求交集,得到候选文件——仅占代码库极小一部分。再仅对这些候选「传统方式」跑正则,确认真实命中。
python
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()
Trigram 索引充当过滤器。若正则含足够字面字符(多数真实模式都满足),候选集可从 5 万文件缩到少数几个,再跑真正正则匹配。误报率足够低,过滤步骤主导性能。
经典 trigram 索引是 Cursor 第一版实现的基础,但他们很快发现纯 trigram 在 monorepo 规模会遇到容量问题:常见 trigram 的 posting list 过大,加载它们几乎和全量扫描一样慢。因此生产索引在两个方向扩展:使用 sparse n-grams——确定性选取、可变长度的 n-gram,而非每个固定 3 字符窗口,索引更小,查询可分解为更少、更精准的查找;并为每个 posting 附加小型 bloom-filter 掩码,编码 trigram 后的字符及出现位置,使 trigram 键控索引能以 quadgram 级精度查询,并验证匹配的 trigram 确实相邻。下文心智模型是经典 trigram 过滤器;线上系统则是不断收紧该过滤器,直到候选集足够小,再用内存映射查找在本地确认。
还有一个容易被忽略但很重要的架构决策:语义索引在 Cursor 服务端,正则索引完全在本地构建和查询。原因是时效性。稍有过时的 embedding 在向量空间里仍大致指向正确方向,语义索引可以略滞后于编辑。正则索引不行:agent 刚写完代码就去搜,索引若还没有,就会陷入无效搜寻。Cursor 让本地索引锚定当前 Git commit,再叠加用户与 agent 编辑,使每次按键更新便宜、启动加载快速。
动态上下文发现
还有一层值得理解。Agent 不会把检索到的所有块一股脑塞进上下文窗口。Cursor 转向他们所称的 dynamic context discovery——以文件为上下文管理单元。
与其急于把检索内容注入 prompt,过大的 tool 响应会写入临时文件。Agent 得到文件路径,可根据当前推理状态决定读取、tail 或 grep。这避免大 tool 响应造成上下文膨胀,同时确保信息不因截断丢失。
终端输出、MCP tool 响应、聊天历史同理。Agent 可能需要的一切都以文件形式可访问;它按需检索,而非预先接收全部。在 MCP tool 使用的 A/B 测试中,这使 agent 总 token 减少 46.9%。这是很有意思的做法,我认为有助于保持上下文窗口小、让 agent 聚焦当前任务。
结语
端到端看完整索引流水线,大致如下:
- 项目打开时:对代码库所有文件构建 Merkle 树
- 语义索引(服务端),首次:用 tree-sitter 分块,用 Cursor 自定义模型 embed,上传到 Turbopuffer 中 per-codebase 的 namespace
- 语义索引,后续打开:计算 simhash,在组织内查找相似已有索引,通过
copy_from_namespace复制最接近的匹配 - 语义索引,后台同步:遍历 Merkle 树 diff 找变更文件,仅 re-embed 那些文件,更新 namespace。Embedding 可容忍一定滞后,因此异步进行
- 正则索引(客户端):在本地构建 trigram / sparse n-gram 索引,以当前 Git commit 为种子,叠加用户与 agent 编辑。与语义索引不同,每次编辑都保持最新——正则若漏掉 model 刚写的代码,会让 agent 白忙一场
- 搜索时:语义查询走 Turbopuffer,并用内容证明做访问控制;正则查询走本地索引过滤候选,再跑真正正则匹配
- 上下文中:检索内容以文件形式供 agent 按需读取,而非预注入 context
我觉得这套架构有意思之处不在任何单一组件——Merkle 树、trigram 索引、基于内容的寻址都已有数十年历史。而在于组合:用 Merkle 树的密码学特性同时做变更检测与访问控制;用 agent 会话轨迹而非代码相似度训练 embedding;以及贯穿全系统的「文件即上下文」抽象。
下次 Cursor 在 5 万文件中半秒内找到你脑海里的那个函数时,你就知道底层发生了什么。