语义相似 ≠ 结构相关:向量检索为什么找不到调用方
利益声明:本文作者参与了 wescode 的开发。文中涉及 wescode 的技术描述基于内部测试数据;涉及其他工具的描述基于各家公开文档和社区反馈。
你让 AI 改一个函数 processPayment,它改得很好。但你问它"还有谁在调用这个函数",它给了你三个答案——cancelPayment、refundPayment、paymentHistory。
看起来很合理对吧?名字里都有 payment。
问题是:真正在调用 processPayment 的是 OrderService.checkout()、一个 Cron Job 和一个 webhook handler。它们的名字里一个 payment 都没有。
AI 给你的不是调用方,是名字像的函数。这是两件完全不同的事。
向量检索的工作原理
Cursor 和大多数 AI 编程工具用的是 Embedding 向量检索。完整流程是这样的:
第一步:切片。代码文件被切成若干片段(通常按函数或固定行数),每段大约 200-500 行。
第二步:上传算向量。这些片段被发送到 Embedding 模型(OpenAI 的 text-embedding-ada-002 或类似模型),每段代码变成一个 1536 维的向量。向量之间的"距离"表示语义相似度。
第三步:存储。向量存在远端数据库里。Cursor 官方说法是"Embedding 和混淆后的文件名长期存在数据库中"(Privacy FAQ)。
第四步:查询。你提问时,问题也被转成向量,找出和问题向量"最近"的代码片段返回给模型。
这个流程的关键特性:它衡量的是语义距离,不是调用关系。 处理支付的代码和取消支付的代码、退款的代码,在向量空间里确实离得很近——因为它们讲的都是钱的事。但"讲的东西像"和"代码里有调用关系"是两件完全不同的事。
| 语义相似 | 结构相关 | |
|---|---|---|
| 含义 | 讲的东西像 | 代码里有调用/依赖/实现关系 |
| 例子 | processPayment 和 refundPayment | OrderService.checkout() 调用了 processPayment |
| 检索方式 | Embedding 向量距离 | 调用图遍历 |
| 回答的问题 | "有没有类似的代码" | "改了这里还要改哪里" |
当你想知道"影响范围"的时候,你需要的是结构关系,不是语义相似。
一个具体的 TypeScript 项目
假设项目里有这样的代码:
// src/controllers/OrderController.ts
class OrderController {
async createOrder(req: Request, res: Response) {
const order = await this.orderService.checkout(req.body)
return res.json(order)
}
}
// src/services/OrderService.ts
class OrderService {
async checkout(input: CheckoutInput): Promise<Order> {
const payment = await this.gateway.processPayment(input.amount)
await this.auditLogger.log('payment_processed', payment)
return { ...input, paymentId: payment.id }
}
}
// src/gateways/PaymentGateway.ts
interface PaymentGateway {
processPayment(amount: number): Promise<PaymentResult>
}
class StripeGateway implements PaymentGateway {
async processPayment(amount: number): Promise<PaymentResult> {
return await this.stripe.charges.create({ amount })
}
}
// src/cron/settlement.ts
class SettlementJob {
async execute() {
const pending = await this.repo.findPendingOrders()
for (const order of pending) {
await this.gateway.processPayment(order.remainingAmount) // 接口调用
}
}
}
调用链是这样的:
OrderController.createOrder()
→ OrderService.checkout()
→ PaymentGateway.processPayment() ← 你要改的函数
→ AuditLogger.log()
SettlementJob.execute()
→ PaymentGateway.processPayment() ← 通过接口间接调用
现在你要改 processPayment 的参数——从 (amount: number) 改成 (amount: number, currency: string)。你需要知道:谁在调用它?
向量检索给你什么
Embedding 按语义相似度排序,返回的前 5 个结果:
| 排名 | 函数 | 为什么排这里 | 是调用方吗 |
|---|---|---|---|
| 1 | PaymentGateway.refundPayment() | 名字最像——都有 Payment | 否,是兄弟方法 |
| 2 | PaymentGateway.validateCard() | 同一个类里 | 否,无调用关系 |
| 3 | PaymentHistory.getRecords() | 名字里有 Payment | 否,无调用关系 |
| 4 | BillingService.calculateFee() | 语义相关(都和钱有关) | 否,无调用关系 |
| 5 | StripeGateway.processPayment() | 接口实现 | ⚠️ 是实现方,不是调用方 |
真正的调用方 OrderService.checkout() 排在第 7-8 位——因为 checkout 和 payment 的语义距离比 refund 远。而 SettlementJob.execute() 可能根本不在返回结果里——因为它通过接口调用,代码里写的是 this.gateway.processPayment(),Embedding 的切片粒度可能没把 gateway 的类型解析和 PaymentGateway 关联起来。
5 个结果,0 个真正的调用方。
grep 给你什么
grep -rn "processPayment" . --include="*.ts"
返回 23 个结果:
| 类型 | 数量 | 是调用方吗 |
|---|---|---|
函数定义(async processPayment(...)) | 2 | 否,是定义 |
直接调用(this.gateway.processPayment(...)) | 2 | 是 |
接口声明(processPayment(amount): Promise<...>) | 1 | 否,是声明 |
测试 mock(mockGateway.processPayment.mockResolvedValue(...)) | 8 | 否,是 mock |
注释(// TODO: 重构 processPayment) | 3 | 否,是注释 |
字符串常量(event: 'processPayment') | 2 | ⚠️ 可能是动态调用 |
类型引用(type ProcessPaymentArgs = ...) | 3 | 否,是类型 |
旧代码(processPaymentLegacy、processPaymentV2) | 2 | 否,是不同函数 |
23 个结果里真正的生产调用只有 2 个。你需要手动排除 21 个干扰项——这就是"一下午"的来源。
而且 grep 有一个致命盲区:SettlementJob 里写的是 this.gateway.processPayment()——grep 搜 processPayment 能搜到。但如果它写的是 this.gateway.process()(接口方法名不一样),grep 就搜不到了。
调用图给你什么
CKG 基于语法解析建好的调用关系,直接返回:
| 调用方 | 关系类型 | 路径 |
|---|---|---|
OrderService.checkout() | CALLS | 直接调用 |
SettlementJob.execute() | CALLS(经 IMPLEMENTS 解析) | 通过 PaymentGateway 接口间接调用 |
2 个结果,2 个真正的调用方。零干扰。
SettlementJob 能被找到,是因为 CKG 知道 this.gateway 的类型是 PaymentGateway,而 StripeGateway 实现了 PaymentGateway(IMPLEMENTS 关系),所以 this.gateway.processPayment() 就是在调用 PaymentGateway.processPayment()。这件事 grep 做不到,Embedding 也做不到——只有语法级别的类型解析才能推导出来。
CKG 怎么建出这张图

wescode 的 CKG(代码知识图谱)通过 10-Pass 深度索引管线 构建调用图。打开项目时自动运行,整条管线分三个阶段:符号采集 → 关系解析 → 全局优化。
tree-sitter:第一步是把代码变成结构
CKG 的底层解析器是 tree-sitter——一个增量式、容错式的 AST 解析器。它与传统编译器的解析方式不同:
- 增量解析:你改了一行代码,tree-sitter 只重新解析被影响的 AST 子树,不是整个文件。对于一个 3000 行的文件,一次键入只需要重解析不到 1% 的节点。
- 容错:你正在写到一半的函数(缺少右括号、缺 return 语句),tree-sitter 仍然能解析出"这是一个函数定义,有这些参数和这些调用语句"。传统 parser 遇到语法错误就停了。
- 语言无关:同一套查询框架适用于 TypeScript、Python、Java、Rust——每种语言只需要一份语法文件(grammar),树的结构查询(S-expression query)是通用的。

举个例子,下面这段 Python 代码经过 tree-sitter 解析后变成 AST:
# src/services/payment_service.py
class PaymentService:
def __init__(self, gateway: PaymentGateway, logger: AuditLogger):
self.gateway = gateway
self.logger = logger
def process_order(self, order: Order) -> Receipt:
result = self.gateway.charge(order.total_amount)
self.logger.log_event("payment_charged", result)
return Receipt(order_id=order.id, payment_id=result.id)
tree-sitter 会把 self.gateway.charge(order.total_amount) 解析为:
(call
function: (attribute
object: (attribute
object: (identifier) @receiver ; "self"
attribute: (identifier) @field ; "gateway"
)
attribute: (identifier) @method ; "charge"
)
arguments: (argument_list ...)
)
CKG 从这棵树里提取出一条调用边:PaymentService.process_order → charge,并且知道 receiver 的类型是 self.gateway(在 __init__ 里声明为 PaymentGateway),所以目标是 PaymentGateway.charge。
10-Pass 管线的具体工作

| Pass | 阶段 | 做什么 | 产出 |
|---|---|---|---|
| 1 | 符号采集 | 文件扫描:遍历项目所有源文件,过滤 node_modules、dist、.git 等目录 | 待处理文件清单 |
| 2 | 符号采集 | 符号提取:用 tree-sitter 解析每个文件的 AST,提取函数、类、接口、变量等符号的定义位置(文件 + 行号 + 限定名) | 符号表 |
| 3 | 关系解析 | 调用关系提取:分析每个函数体内的调用语句,提取调用边 A → B。receiver 保留原始拼写(svc.CreateUser 不猜测大写),变量 receiver 先标为 unresolved | 原始调用边(含 unresolved) |
| 4 | 关系解析 | 跨文件边解析:用三级回退策略解析 unresolved 的调用目标——①精确 Type.Method 匹配 → ②包路径 %/pkg/% 匹配 → ③裸名单候选回退(只有恰好一个同名符号才绑定,多候选保持 unresolved 留给查询层分组呈现) | resolved 调用边 |
| 5 | 关系解析 | 接口实现:分析 implements、extends、Python 的 class X(Base) 声明,建立 IMPLEMENTS 边 | StripeGateway → PaymentGateway |
| 6 | 关系解析 | 接口方法绑定:对于 this.gateway.processPayment() 这类通过接口变量调用的语句,把 receiver 类型推导到接口,再通过 IMPLEMENTS 边找到所有实现方 | 间接调用边 |
| 7 | 全局优化 | 导入依赖:分析 import / from ... import / require 语句,建立包级别的 IMPORTS 边 | 包依赖图 |
| 8 | 全局优化 | 跨文件符号引用:将同一符号在不同文件中的引用统一到同一个节点 | 去重的符号引用 |
| 9 | 全局优化 | 覆写关系:分析 @Override、方法签名匹配、Python 的 MRO 继承链,建立 OVERRIDES 边 | 覆写关系图 |
| 10 | 全局优化 | 关系优化:消除重复边、合并别名、建立查询索引(SQLite FTS5 + B-Tree) | 完整的知识图谱 |
Pass 5-6 是 CKG 与 grep/Embedding 拉开差距的关键。看这段 Java 代码:
// src/main/java/com/example/service/NotificationService.java
public class NotificationService {
private final MessageSender sender; // 接口类型
public void notifyUser(String userId, String content) {
Message msg = Message.builder()
.to(userId)
.body(content)
.build();
sender.send(msg); // 通过接口调用
}
}
// src/main/java/com/example/infra/EmailSender.java
public class EmailSender implements MessageSender {
@Override
public void send(Message msg) {
// SMTP 发送逻辑
}
}
// src/main/java/com/example/infra/SmsSender.java
public class SmsSender implements MessageSender {
@Override
public void send(Message msg) {
// 短信网关发送逻辑
}
}
grep send( 会命中整个项目里几百个叫 send 的方法。Embedding 搜 MessageSender.send 会给你一堆和"发送消息"语义相近的代码。但 CKG 能精确告诉你:
NotificationService.notifyUser()CALLSMessageSender.send()EmailSenderIMPLEMENTSMessageSenderSmsSenderIMPLEMENTSMessageSender- 因此改
MessageSender.send()的签名,必须同时改EmailSender.send()和SmsSender.send()
这就是结构关系和语义相似的根本区别。
六种关系边
| 关系 | 含义 | 例子 | grep 能找到吗 |
|---|---|---|---|
| CALLS | A 调用 B | checkout() 调用 processPayment() | 直接调用可以 |
| IMPLEMENTS | A 实现接口 B | StripeGateway 实现 PaymentGateway | 不能 |
| EMBEDS/EXTENDS | A 继承 B | PremiumOrder 继承 Order | 能 |
| IMPORTS | 包 A 依赖包 B | order/ 导入 payment/ | 能 |
| DEFINES | 文件 A 定义符号 B | gateway.ts 定义 processPayment | 能 |
| OVERRIDES | A 覆盖父类方法 B | EmailSender.send() 覆盖 MessageSender.send() | 不能 |
grep 在 6 种关系里有 4 种能找到,但 IMPLEMENTS 和 OVERRIDES 完全失效——而这两种恰恰是大型 TypeScript / Java 项目里最密集的关系。接口抽象越多、依赖注入越重的项目,grep 的盲区越大。
索引性能
所有索引在本地完成,存储在本地 SQLite。不上传任何代码到服务器。
| 项目规模 | 首次索引 | 增量更新(文件保存后) | 索引大小 |
|---|---|---|---|
| 1 万行 | < 2 秒 | < 50ms | ~1MB |
| 10 万行 | 5-15 秒 | 100-200ms | ~10MB |
| 50 万行 | 30-60 秒 | 200-500ms | ~50MB |
| 100 万+ | 1-3 分钟 | 300-800ms | ~100MB |
以上性能数据为内部测试数据,测试环境为 macOS Apple Silicon。
对比 Cursor 的 Embedding 索引——Cursor 需要把代码分块上传到服务端算向量,受 Embedding API 吞吐量限制。官方博客承认大仓索引 "could take hours"(Secure Codebase Indexing)。CKG 是纯本地 CPU 计算,不受网络和 API 限速约束。
手动搜索 vs CKG:一次重构的 Before / After
假设你在一个 15 万行的 TypeScript 电商项目里,需要把 UserService.getProfile() 的返回值从 UserProfile 改成 UserProfileV2(新增了几个字段)。你需要找到所有消费了返回值的地方。
Before(手动搜索 + grep)
| 步骤 | 操作 | 耗时 |
|---|---|---|
| 1 | grep -rn "getProfile" . --include="*.ts" 得到 47 个结果 | 1 秒 |
| 2 | 排除函数定义本身(3 处)、测试 mock(12 处)、注释(5 处)、类型声明(4 处) | 10 分钟 |
| 3 | 剩下 23 处手动逐个检查:哪些是直接调用、哪些是同名但不同类的方法、哪些是字符串引用 | 20 分钟 |
| 4 | 发现有 3 处通过接口 IUserService 调用的——grep 到了字符串但不确定 svc.getProfile() 里的 svc 是不是 UserService 类型,需要手动往上翻看变量声明 | 15 分钟 |
| 5 | 漏掉了 AdminDashboard 里通过依赖注入拿到的 IUserService(变量名叫 profileLoader),因为 grep getProfile 能搜到但你在第 3 步以为它是另一个类的方法 | 直到上线报错才发现 |
| 合计 | 45 分钟 + 1 个遗漏 |
After(CKG 搜索)
| 步骤 | 操作 | 耗时 |
|---|---|---|
| 1 | 在 wescode 里选中 getProfile,CKG 返回 8 个直接调用方 + 2 个通过 IUserService 接口的间接调用方 | < 1 秒 |
| 2 | 所有 10 个调用方都带文件路径和行号,逐个点击跳转确认 | 3 分钟 |
| 3 | AdminDashboard.profileLoader.getProfile() 在列表里——因为 CKG 知道 profileLoader 的类型是 IUserService,而 UserService 实现了 IUserService | 0(自动包含) |
| 合计 | 3 分钟 + 0 个遗漏 |
时间从 45 分钟降到 3 分钟,准确率从"可能漏"变成"保证不漏静态可分析的调用"。
三种方式的完整对比

| 维度 | grep | Embedding 向量检索 | CKG 调用图 | 代价 / 局限 |
|---|---|---|---|---|
| 原理 | 字符串匹配 | 语义向量距离 | 语法解析 + 图遍历 | — |
| 找到直接调用方 | 能(名字出现在代码里时) | 不一定(看语义距离) | 能,精确 | 动态派发不可追踪 |
| 找到接口间接调用 | 不能 | 不一定 | 能 | 反射调用不可追踪 |
| 找到多层影响链 | 不能 | 不能 | 能(A→B→C 一次遍历) | 深度越深,动态调用盲区越可能出现 |
| 干扰项 | 注释/mock/字符串/旧代码 | 名字像但没关系的函数 | 无 | — |
| 需要联网 | 否 | 是(上传算 Embedding) | 否(本地构建) | — |
| 索引速度(10万行) | 即时(不做索引) | 分钟到小时级 | 5-15 秒(内部测试数据) | 首次仍需等待 |
| 数据安全 | 不涉及 | 代码片段上传服务端 | 代码不出设备 | 本地模型推理质量低于商业大模型 |
| 适合回答的问题 | "哪些文件包含这个字符串" | "有没有语义类似的代码" | "改了这里还要改哪里" | 不提供"语义相似代码"推荐 |
三种方式各有用处。向量检索在"找类似实现"的场景下很好用——比如你想知道项目里有没有类似的排序逻辑,Embedding 比 grep 好用得多。grep 在配置文件搜索(YAML/JSON 里的字符串引用)上不可替代。
但当你问的是"改了这里还要改哪里"——这是每次修改前最关键的问题——只有调用图能给你一个准确且完整的答案。
各工具用了哪种
| 工具 | grep | Embedding | 调用图 | "改了这里还影响哪里"怎么回答 |
|---|---|---|---|---|
| Cursor | 有 | 有(主力) | 无 | 靠 Embedding 找"语义相近"的代码,不保证完整 |
| GitHub Copilot | 有 | 有 | 无 | 靠 Embedding + LSP 引用 |
| Claude Code | 有(主力) | 无 | 无 | 靠 grep + 逐文件 read,每次从头搜 |
| wescode | 有 | 无 | 有(CKG) | 图遍历,精确且完整 |
| 通义灵码 | 有 | 有 | 无 | 靠 Embedding |
| Trae | 有 | 有 | 无 | 靠 Embedding |
wescode 选了调用图(CKG),不做 Embedding。这是一个明确的取舍——放弃了"找类似代码"的便利性,换来了"找影响范围"的精确性。Embedding 的优势(语义搜索)可以通过 grep + CKG 结合覆盖大部分场景;但调用图的优势(接口间接调用、多层影响链)是 Embedding 无论怎么优化都做不到的——因为语义距离和调用关系是两个正交的维度。
CKG 做不到什么
诚实说,CKG 不是万能的:
| 做不到 | 为什么 | 替代方案 |
|---|---|---|
动态派发(obj[methodName]()) | 运行时才能确定调用目标 | grep 兜底 |
| 配置文件的字符串引用(YAML/JSON) | 不是代码,tree-sitter 不解析 | grep 兜底 |
| 跨语言 FFI(TypeScript 调 WebAssembly) | 语言边界断开 | 手动确认 |
| 元编程 / 代码生成 | CKG 看的是源码不是生成物 | 看生成后的代码 |
极度动态的 Python 元类(type() / __getattr__ 黑魔法) | 运行时动态生成属性和方法 | 运行时 profiling |
覆盖率在大多数业务项目上约 85-95%(内部测试数据)——因为大多数代码的调用关系是静态可分析的。剩下的 5-15% 靠 grep 兜底。不完整但精确的结果,比完整但充满干扰的结果有用得多。
常见问题
Q:能不能两个都做,Embedding + 调用图? A:技术上可以。但两种结果混在一起展示时,用户更难分辨哪些是"真的有关系"、哪些是"名字像"。wescode 选择了只做调用图——宁可不给你"类似代码"的推荐,也不让干扰项混进影响分析结果。
Q:调用图对动态语言(Python / JavaScript)准确吗?
A:静态分析对 Python 的 duck typing、JavaScript 的 obj[methodName]() 确实有盲区。但大多数业务代码的调用关系是静态可分析的——你的 Service 调 Repository、Controller 调 Service,这些都是确定性的。覆盖率 85-95%,比向量检索的"名字像就塞进来"准确得多。
Q:Cursor 以后会加调用图吗? A:不知道。我们只对比当前已发布的能力。Cursor 的架构选择是把代码上传到云端做 Embedding——要加调用图意味着要么在云端做语法解析(和 Embedding 一样有延迟和安全问题),要么重构为本地分析。这是架构层面的决策,不是"加一个功能"那么简单。
Q:CKG 对大型 monorepo(百万行以上)的表现如何? A:CKG 在 100 万行以上的项目首次索引需要 1-3 分钟,之后增量更新(每次保存文件)在 300-800ms 以内完成。关键是 tree-sitter 的增量解析特性——你改了一个文件,只需要重新解析那一个文件的 AST 并更新受影响的边,不需要全量重跑。对比 Embedding 方案在大仓上"could take hours"的首次索引和每次代码变更后需要重新计算向量的延迟,CKG 在 monorepo 场景下的优势更明显。实际生产中,一个 200 万行的 TypeScript monorepo 索引占 ~200MB 本地磁盘,日常使用感受和 10 万行项目没有明显差别。
本文对 Cursor 的描述基于其 2026-09-20 的官方文档(Privacy FAQ、Secure Codebase Indexing)。如有更新,以各家最新页面为准。