☰
百万行项目建索引:Cursor 官方说要数小时,别家呢
2026/9/28 19:46:58 网站建设 项目流程

利益声明:本文作者参与了 wescode 的开发。文中涉及 wescode 的技术描述基于内部测试数据;涉及其他工具的描述基于各家公开文档和社区反馈。

周五下午,你git clone了一个 80 万行的 monorepo,打开 Cursor,右下角开始转圈——"Indexing codebase..."。

你去倒了杯水,回来还在转。又过了十分钟,还在转。你问同事,同事说:"第一次打开大项目都这样,等着吧。"

半小时后你放弃等待,开始写代码。AI 补全给了你一个createUser(name, email)——你照着写了,测试跑不过。翻了一下代码发现,上个月重构把签名改成了createUser(params: CreateUserInput)。你花了 20 分钟 debug 一个本不该存在的问题。

为什么?因为索引还没建完。AI 看到的函数签名是旧的,它很自信地给你了一个过时的正确答案。

索引速度不是体验问题,是正确性问题。索引没建完的 AI,和没有 AI 一样——甚至更差,因为它给的建议看起来很自信,但底下的数据可能是半成品。你没法区分"AI 基于完整信息给的好建议"和"AI 基于残缺索引猜的"。

做个思想实验:如果索引能在 2 分钟内建完,上面这个场景就不会发生。你打开项目、喝口水、回来——索引已经好了,AI 给的签名就是最新的那个。

"等多久"直接决定了"AI 靠不靠谱"的时间窗口。索引建了 30% 的时候 AI 给的建议和索引建了 100% 的时候可能完全不同,但它们在界面上长得一模一样——同样自信的补全、同样流畅的建议。区别在于一个是基于完整事实,另一个是基于部分事实的猜测。


两种索引,两种瓶颈

索引代码有两条主流技术路线。它们的性能差异不是"快一点慢一点",而是瓶颈发生在完全不同的地方。

一个类比:

  • CKG 索引像是在自家厨房做饭——食材、灶台、锅碗瓢盆都在手边,做多快取决于你切菜的速度(CPU)。
  • Embedding 索引像是点外卖——你下单、等骑手取餐、等配送,做多快取决于平台的接单速度和路上堵不堵车(网络 + API 排队)。

厨艺再差也比等外卖快,因为瓶颈在不同的地方——一个在你手里,一个在路上。

CKG:瓶颈在 CPU

wescode 的 CKG(代码知识图谱)用 tree-sitter 在本地解析源码,通过10-Pass 深度索引管线构建完整的代码结构图。全程在你自己的电脑上运行,链路是这样的:

读文件 (μs) → tree-sitter 解析 AST (μs/文件) → 10-Pass 提取符号 + 关系边 → SQLite 写入 (ms/批) ↓ 总瓶颈 = CPU 单核解析速度

每一步都在本机完成。读文件是微秒级的磁盘 I/O,tree-sitter 解析一个文件的 AST 也是微秒级(tree-sitter 是 C 写的增量解析器,极快),批量写入 SQLite 是毫秒级。整条链路没有网络请求,没有排队,没有外部依赖。

10-Pass 管线做的事情:

  1. 文件扫描——遍历 workspace,跳过.gitignore和非源码文件
  2. 符号提取——tree-sitter 解析每个文件的 AST,提取函数、类、接口、变量等符号定义
  3. 调用关系解析——分析函数体内的调用表达式,建立CALLS边
  4. 接口实现解析——分析类型系统,建立IMPLEMENTS边
  5. 继承/嵌入解析——分析extends/embed关系,建立EMBEDS-EXTENDS边
  6. 导入依赖——分析import/require语句,建立IMPORTS边
  7. 定义解析——分析包级别导出和模块定义,建立DEFINES边
  8. 覆写关系——分析方法重写(如 Go 的接口隐式实现),建立OVERRIDES边
  9. 跨文件引用优化——消解全局别名、re-export 链
  10. 边去重和完整性校验——最终一致性检查

最终产出六种关系边——CALLS、IMPLEMENTS、EMBEDS-EXTENDS、IMPORTS、DEFINES、OVERRIDES——覆盖了代码中绝大多数静态可分析的结构关系。

为什么要分 10 个 Pass 而不是一趟扫完?因为后面的 Pass 依赖前面的结果:你要先知道所有符号的定义位置(Pass 2),才能解析调用关系指向哪个定义(Pass 3-8);你要先有完整的导入图(Pass 6),才能消解跨文件的别名和 re-export(Pass 9)。分 Pass 不是效率问题,是正确性问题。一趟扫完只能拿到"文件 A 里出现了 createUser 这个字符串";10-Pass 扫完能拿到"文件 A 第 42 行调用的 createUser 指向文件 B 第 15 行的定义,并且文件 C 实现了这个接口"。

用一段 TypeScript 代码来具象化这个区别:

// file: src/services/user-service.ts import { UserRepository } from '../repositories/user-repo'; import { EmailService } from '../services/email-service'; export class UserService { constructor( private repo: UserRepository, private emailSvc: EmailService ) {} async createUser(params: CreateUserInput): Promise<User> { const user = await this.repo.insert(params); await this.emailSvc.sendWelcome(user.email); return user; } }

一趟 grep 能告诉你"createUser 出现在 user-service.ts 第 12 行"。10-Pass CKG 能告诉你:createUser定义在UserService(DEFINES),它调用了UserRepository.insert和EmailService.sendWelcome(CALLS),UserRepository从../repositories/user-repo导入(IMPORTS),而且UserService实现了IUserService接口(IMPLEMENTS)。是"出现过"和"怎么连的"的区别。

Embedding:瓶颈在网络和 API 排队

Cursor 等工具用的 Embedding 索引,流程长得多:

读文件 → 分块 (200-500 行/块) → HTTP 上传到 API → 等 API 排队 → GPU 计算向量 (ms/块) ↓ ← 返回结果 ← 存储到向量数据库 总瓶颈 = 网络延迟 + API 吞吐限制

GPU 算一个向量只需要几毫秒——Embedding 本身不慢。慢在传输和排队:代码要切片、上传、等 API 处理、结果回传。并发受 rate limit 约束,网络延迟不可消除。你的网速再快,API 那边的队列也不会因此变短。

还有一个容易忽略的点:冷启动问题。首次打开一个大项目,Embedding 索引需要从零开始上传全部代码。而 CKG 只需要在本地跑一遍 tree-sitter——不依赖网络,不需要等服务端排队,打开编辑器就开始解析。

用数字说明差距

100 万行代码 ≈ 5000 个源文件。两条路线处理同样的代码量:

维度CKG(本地结构化)Embedding(云端向量化)
待处理单元5000 个文件(逐文件解析 AST)~20000 个块(按 200-500 行切片)
单元处理速度μs/文件(CPU 本地解析)ms/块 + 网络往返(数十~数百 ms)
并发限制无(本地 CPU 直接跑)API rate limit(通常 100-300 次/分钟)
100 万行总耗时1-3 分钟(内部测试数据)1-3 小时(受 rate limit 约束)
耗时比1×60-100×

Cursor 官方博客Secure Codebase Indexing明确提到大型项目索引 "could take hours"。这不是 Cursor 做得差——是 Embedding 这条路线的物理限制。你不可能让外卖比自己做饭更快,因为瓶颈在路上,不在厨房。


四档项目的实际体感

以下数据基于 macOS(Apple Silicon M2 Pro,32GB 内存)测试(内部测试数据),混合语言项目(TypeScript + Python 为主):

项目规模CKG 首次索引增量更新索引大小体感类比
1 万行(微服务)< 2 秒< 50ms~1MB你按下回车,索引就好了
10 万行(中型项目)5-15 秒100-200ms~10MB喝口水的功夫
50 万行(monorepo 组件)30-60 秒200-500ms~50MB上个厕所回来
100 万行(超大 monorepo)1-3 分钟300-800ms~100MB泡杯咖啡等一会

对比 Embedding:同样 100 万行,Cursor 需要"开一轮会议的时间"。

关键区别不是"快几倍"——是体验模式完全不同。CKG 索引的等待时间在人的耐心阈值之内:30 秒以内你会等,1 分钟你去倒杯水,3 分钟你回来就好了。Embedding 索引动辄半小时起步,你的工作节奏被打断,而且在整个等待期间 AI 给的建议质量是不确定的——你不知道它是基于 30% 的索引还是 80% 的索引在给建议。

另一个经常被忽略的维度是重建成本。换台电脑、重装系统、或者索引文件损坏时怎么办?CKG 删了索引目录,重开编辑器跑一遍——几十秒到几分钟搞定。Embedding 索引要重新把全量代码上传到云端重算——回到"首次索引"的等待循环。

这也意味着 CKG 索引是真正可丢弃的——它不是需要小心维护的宝贵资产,而是随时可以从源码重建的派生数据。你不需要操心"索引文件要不要备份""换电脑怎么迁移索引"这类问题。


增量更新:比首次索引更重要

首次索引只发生一次。日常开发中你每天保存文件几百次,每次都需要索引跟上。增量更新的速度决定了 AI 在你日常编码中是否可靠。

wescode CKG 的增量流程

文件保存后,CKG 的更新分四步完成:

步骤做什么技术细节耗时
① 事件检测捕获文件变更fsnotify 文件系统监听 + 编辑器保存事件回调~0ms
② 重新解析 AST只解析变更文件tree-sitter 增量解析,复用未变更的 AST 子树1-5ms/文件
③ 更新关系边变更文件 + 直接依赖例:你改了接口定义,实现该接口的文件重新解析 IMPLEMENTS 边50-200ms
④ 原子写入SQLite 事务提交一个事务内完成所有变更,不会出现"写了一半"的中间状态10-50ms
总计100-500ms

第 ② 步值得展开说。tree-sitter 的增量解析不是"重新解析整个文件"——它只重新解析 AST 中变更的子树。如果你改了一个函数体,其他函数的 AST 节点直接复用。这是 tree-sitter 设计之初就内建的能力,不是上层优化。

第 ③ 步的"直接依赖"是什么意思?用一段 Python 代码举例:

# file: services/order_service.py from repositories.order_repo import OrderRepository from services.payment_service import PaymentService class OrderService: def __init__(self, repo: OrderRepository, payment: PaymentService): self.repo = repo self.payment = payment def place_order(self, user_id: str, items: list[dict]) -> Order: order = self.repo.create(user_id=user_id, items=items) self.payment.charge(user_id=user_id, amount=order.total) return order def cancel_order(self, order_id: str) -> None: order = self.repo.get(order_id) self.payment.refund(order_id=order_id, amount=order.total) self.repo.update_status(order_id, "cancelled")

假设你修改了PaymentService.charge的签名,从charge(user_id, amount)改成charge(payment_request: PaymentRequest)。CKG 需要重新检查所有CALLS边指向这个方法的文件——在这个例子里是OrderService.place_order——但不需要检查cancel_order(它调用的是refund,没变)。它通过关系边的target_id索引反查调用方,只重新解析这些文件的相关边。这就是图索引的优势:变更的传播范围是可计算的,不需要全量扫描。

关键特性:耗时取决于变更文件数,不取决于项目总规模。一个 100 万行的项目改了 3 个文件,增量更新耗时和一个 1 万行的项目改了 3 个文件基本一致——都在 100-500ms 以内。

这意味着:你保存文件,切到下一个 tab 的时候,索引已经更新完了。你感知不到"索引正在重建"这件事。

Embedding 的增量困境

Embedding 索引的增量更新面临三个固有困难:

  1. 粒度粗——改了一行也要重新 Embedding 整个代码块(200-500 行),因为向量是按块计算的
  2. 需要网络——更新后的块要上传到服务端重新计算向量,受网络延迟和 API 排队影响
  3. 批量变更雪崩——git pull拉了 50 个文件的更新,需要重新上传所有受影响的块;规模越大等待越久。而 CKG 处理同样的 50 个文件变更,只是本地跑 50 次 tree-sitter 解析 + 更新受影响的边——仍然是亚秒级完成,因为瓶颈始终在本地 CPU,不会因为变更量大就从"秒级"跳到"分钟级"

Cursor 是否实现了真正的增量更新,官方文档未公开细节。从使用体验看,大批量文件变更后确实有一段"索引追赶"时间——期间 AI 的上下文质量会下降。

最关键的问题:你不知道索引是否已经更新完。Cursor 没有可见的索引进度指示器。AI 给了一个建议,你无法确定它是基于最新代码还是过时的索引。wescode 的状态栏有明确的索引进度——索引中显示百分比,完成后自动消失。不完整就告诉你不完整,完成了就是完成了。


同一个重构:有索引 vs 没索引的差距

来看一个真实场景。你要把UserService的错误处理从返回 null 改为抛异常:

// Before: 返回 null(已废弃的模式) public class UserService { public User findUser(String userId) { User user = userRepository.findById(userId); return user; // 找不到返回 null } } // After: 抛异常(新规范) public class UserService { public User findUser(String userId) { return userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException(userId)); } }

这个改动影响所有调用findUser的地方——因为它们之前用if (user == null)做判空,现在需要改成try-catch。

步骤索引不完整的 AI索引完整的 AI
① 找调用方只看到 3 个(索引覆盖的文件)找到全部 7 个调用方
② 判断影响"改动影响不大,3 个文件需要更新""7 个文件需要更新,含 2 个批处理任务"
③ 生成补丁只给出 3 个文件的修改给出全部 7 个文件的修改
④ 遗漏后果被遗漏的 4 个调用点在生产环境抛出未处理异常无遗漏
总耗时修改 10 分钟 + 排查未处理异常 2 小时修改 15 分钟,一次完成
准确率43%(3/7)100%(7/7)

遗漏率不是线性的。缺失的不是"4 个无关紧要的文件"——缺失的往往是依赖链更深、更不容易被手动发现的调用点。在这个例子中,被遗漏的两个批处理任务在凌晨运行,你可能几天后才发现生产环境在报UserNotFoundException。

这就是为什么说索引速度是正确性问题——不完整的索引 → 不完整的调用图 → 不完整的修改建议 → 线上事故。整条链路的起点就是"索引还没建完"。


CKG 的存储格式和查询效率

CKG 索引存在本地 SQLite 数据库中,位置在$XDG_DATA_HOME/wescode/cells/ws-{hash}/index/,跟随 workspace。内部由四个存储层组成:

存储层技术查询类型典型延迟
符号表SQLite 表,按文件 + 行号索引点查("这个符号定义在哪")< 1ms
关系边SQLite 表,按 source_id / target_id 索引图遍历("谁调用了这个函数")1-10ms
全文搜索SQLite FTS5 虚拟表文本搜索("包含 payment 的函数")1-5ms
文件元数据SQLite 表文件存在性检查 / 变更追踪< 1ms

为什么选 SQLite 而不是专用图数据库?因为代码知识图谱的查询模式是"从一个节点出发,遍历 1-3 层邻居"——SQLite 的 B-tree 索引对这种有界遍历已经足够快(1-10ms),不需要 Neo4j 那种专用图数据库的开销和运维成本。SQLite 是零配置的——不需要额外启动服务、不需要网络端口、不需要独立进程。打开编辑器就能用,关掉编辑器就没了。

对比存储效率

维度CKG(SQLite)Embedding(向量数据库)
10 万行索引体积~10MB~200-500MB(高维向量)
100 万行索引体积~100MB数 GB
存储内容符号定义 + 关系边(结构化数据)1536 维浮点向量 × 代码块数
存储位置本地磁盘,代码不离开设备云端(Cursor 说法:Embedding 长期存储在数据库中)
可移植性删了重建只需几十秒到几分钟重建需要重新上传全量代码

结构化索引存的是"谁调用了谁"这种关系,天然比高维浮点向量紧凑得多。同样 100 万行代码,一个 100MB,一个可能占几个 GB——差距主要来自 1536 维浮点数 vs 两个整数 ID 的存储密度差异。


Cursor 大仓索引的三个实际影响

Cursor 的Secure Codebase Indexing博客承认大型项目索引耗时较长。这个耗时在日常使用中带来三个具体问题:

1. 索引期间 AI 的上下文质量下降。索引没建完时,AI 只能看到已索引的部分代码。它可能推荐一个已经被废弃的 API、补全一个已经改名的函数——不是因为它笨,是因为它看到的信息就是不完整的。更糟的是,你分不清这个建议是"基于完整信息的好建议"还是"基于残缺索引的猜测"。

2. 大规模git pull后需要等待重新索引。你拉了同事一周的提交(几十个文件变更),Embedding 索引需要重新上传和计算。等待时间里 AI 可能基于过时数据给建议——你改了接口签名,AI 还在用旧签名补全。这个场景在团队协作中几乎每天都会遇到。

3. 没有索引进度指示。你不知道索引是否完成,也不知道 AI 的建议是基于完整索引还是不完整索引。这种不确定性会侵蚀你对工具的信任——每次 AI 给建议,你都在心里打个问号:"这是基于最新代码吗?" 具体来说,你在 Cursor 里看不到类似"索引完成 73%"这样的进度条。索引在后台默默进行,你只能靠 AI 回答的质量间接推测。


三种路线完整对比

维度Cursor(Embedding)Claude Code(实时搜索)wescode(CKG)代价 / 局限
索引方式云端 Embedding 向量无索引(每次 grep + read)本地 tree-sitter + SQLite—
首次索引(10 万行)5-30 分钟无需索引5-15 秒(内部测试数据)首次仍需等待,不如"零索引"即时
首次索引(100 万行)1-3 小时无需索引1-3 分钟(内部测试数据)超大仓需等几分钟
增量更新文件级重算,需上传每次实时搜索100-500ms,纯本地(内部测试数据)—
代码上传需要(Embedding 在服务端计算)按需(搜索时通过 API)不需要—
索引位置云端无本地 SQLite—
索引进度可见无明确指示不适用状态栏显示进度—
理解深度语义级(向量相似度)搜索 + LLM 推理结构级(确定性关系图)不提供"语义相似代码"推荐
离线可用已有索引缓存可查搜索命令可离线完全离线离线时无法调用云端 LLM
索引体积(10 万行)~200-500MB0~10MB(内部测试数据)—

三条路线各有适用场景。Embedding 的语义搜索在"找类似代码"时很好用——比如"有没有类似的表单校验逻辑"这种模糊需求。Claude Code 的零索引策略对小项目很友好——不需要等索引,打开就能用。CKG 的结构化索引在"找调用方""分析影响范围""大仓日常开发"这些场景下有不可替代的优势——确定性的调用关系是 Embedding 和 grep 都给不了的。


FAQ

Q1:Cursor 官方说的"数小时"是什么条件下?

Cursor 的Secure Codebase Indexing博客原文提到大型 codebase 的索引耗时较长。核心限制是 Embedding API 的吞吐——需要将大量代码片段逐批发送到服务端计算向量。10 万行以下通常几分钟完成,但 monorepo 级别(50 万行+)可能等上小时级别。即使 Cursor 有内部优先队列或批处理优化,网络往返和 API 排队的物理下限是无法消除的。

Q2:CKG 索引占多少磁盘空间?

取决于项目大小和语言种类。10 万行 TypeScript 项目约 10MB(含 SQLite 索引和 FTS5 全文索引),100 万行项目约 100MB。结构化索引存的是关系边(整数 ID + 枚举类型),不是高维浮点向量——向量索引同规模项目通常占 200-500MB。而且索引是可以随时删了重建的,没有数据迁移的心智负担。

Q3:增量更新 100-500ms,用户真的感知不到吗?

人类对视觉变化的感知阈值大约在 100ms。CKG 的增量更新在你保存文件、切换 tab 的时间内就完成了。对比 Embedding 的增量更新可能需要几十秒到几分钟(取决于网络和 API 排队),差异是"无感更新"和"需要等待并且等待期间结果不可靠"的区别。

Q4:CKG 和 Embedding 能不能结合使用?

技术上可以。但 wescode 选择了只做 CKG——宁可不给你"语义类似代码"的推荐,也不让干扰项混进影响分析结果。语义搜索的场景可以用 grep + LLM 推理覆盖;但调用图的精确性是 Embedding 无论怎么优化都给不了的——因为语义距离和调用关系是两个正交的维度。"名字像"不等于"真的调了"。

Q5:企业选型该怎么考虑索引方案?

三个维度递进判断:① 代码是否允许离开本地(合规)→ 排除云端 Embedding 方案;② 项目规模是否大到索引时间成为问题(效率)→ 选本地方案;③ 团队是否需要确定性的代码结构分析(质量)→ 选 CKG。对合规敏感的企业,wescode(全本地,不上传代码)是最直接的选择。

Q6:tree-sitter 支持哪些语言?

tree-sitter 有社区维护的语法定义覆盖 200+ 语言。wescode 的 CKG 管线对 TypeScript、Python、Java、Rust、C/C++ 等主流语言做了深度适配(完整的六种关系边解析)。其他 tree-sitter 支持的语言可以做基础符号提取,但跨文件关系分析的覆盖度取决于语言特性和适配深度。

Q7:索引数据会上传到云端吗?

不会。CKG 的全部数据——AST 解析、符号表、关系边、FTS5 索引——都在你本地的 SQLite 文件中。代码不离开设备,索引也不离开设备。删除 workspace 的索引目录就是清除全部索引数据,没有云端残留。对于处理敏感代码(金融、医疗、政府项目)的团队来说,这个特性不是加分项,是入场条件。

Q8:CKG 对动态语言的覆盖率如何?

静态类型语言(TypeScript、Java、Rust)的覆盖率通常在 90-95%,因为调用关系在源码中已经明确。动态语言(Python、JavaScript)的覆盖率约 80-90%——duck typing、装饰器改写、反射调用等动态特性不可静态分析。但即使 80% 的覆盖,也意味着每 10 个函数调用有 8 个是确定性的结果,比纯 grep 的"文本碰撞"可靠得多。


CKG 做不到什么

诚实说:

做不到的场景原因替代方案
动态派发(obj[methodName]())运行时才能确定调用目标grep 兜底
配置文件中的字符串引用YAML/JSON 不是代码,tree-sitter 不解析grep 兜底
"找语义类似的代码"CKG 只看结构关系,不做语义相似度grep + LLM 推理
跨语言 FFI(如 TS 调 WebAssembly)语言边界断开,解析器无法跨越手动确认
元编程 / 代码生成CKG 分析的是源码,不是生成物索引生成后的代码
反射调用(如 JavaClass.forName)类名和方法名是字符串,静态分析不可达grep 兜底

覆盖率在大多数业务项目上约 85-95%(内部测试数据)——因为大多数代码的调用关系是静态可分析的。剩下的 5-15% 靠 grep 兜底,AI 的 LLM 推理能力可以在 grep 结果上做进一步判断。不完整但精确的结果,比完整但充满干扰的结果实用得多。你宁可 AI 说"我不确定这个动态调用指向哪里",也不想它自信地告诉你一个语义相似但实际上从来没被调用过的函数。

这里有一个常见误解需要澄清:覆盖率不等于准确率。CKG 说"A 调用了 B",那就是真的调用了——精确率接近 100%,因为它是从 AST 中直接提取的确定性关系。CKG 漏掉的只是它看不见的那部分(动态、反射、元编程)。Embedding 的问题方向相反——它可能说"A 和 B 相关",但这个"相关"可能只是名字像、注释像,并不代表存在真实的调用关系。一个有漏报但无误报,一个误报和漏报都有——这是两种完全不同的错误模式。


索引速度决定了一件看不见的事:你信任 AI 给的每一个建议需要多长时间。索引建了 30% 时 AI 说的话和建了 100% 时说的话可能完全矛盾,但它们在屏幕上看起来一模一样。缩短这个不确定窗口,就是缩短"AI 可能在骗你"的时间。

CKG 把这个窗口从小时级压缩到了分钟级。对于百万行级别的项目,这意味着你从"开完会回来索引可能好了"变成"泡完咖啡就能完全信任 AI 的每一条建议"。

而且这个速度不是靠牺牲深度换来的——10-Pass 管线产出的六种关系边,是 grep 和 Embedding 都给不了的结构化事实。快,并且准。

剩下的,你自己试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询