语义相似 ≠ 结构相关:向量检索为什么找不到调用方

wescode · 2026-09-23 · CKG / Embedding / 代码理解

利益声明:本文作者参与了 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 和 refundPaymentOrderService.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 个结果:

排名函数为什么排这里是调用方吗
1PaymentGateway.refundPayment()名字最像——都有 Payment否,是兄弟方法
2PaymentGateway.validateCard()同一个类里否,无调用关系
3PaymentHistory.getRecords()名字里有 Payment否,无调用关系
4BillingService.calculateFee()语义相关(都和钱有关)否,无调用关系
5StripeGateway.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 怎么建出这张图

CKG 代码知识图谱全景

wescode 的 CKG(代码知识图谱)通过 10-Pass 深度索引管线 构建调用图。打开项目时自动运行,整条管线分三个阶段:符号采集 → 关系解析 → 全局优化。

tree-sitter:第一步是把代码变成结构

CKG 的底层解析器是 tree-sitter——一个增量式、容错式的 AST 解析器。它与传统编译器的解析方式不同:

tree-sitter AST 解析流程

举个例子,下面这段 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 管线的具体工作

CKG 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 能精确告诉你:

这就是结构关系和语义相似的根本区别。

六种关系边

关系含义例子grep 能找到吗
CALLSA 调用 Bcheckout() 调用 processPayment()直接调用可以
IMPLEMENTSA 实现接口 BStripeGateway 实现 PaymentGateway不能
EMBEDS/EXTENDSA 继承 BPremiumOrder 继承 Order能
IMPORTS包 A 依赖包 Border/ 导入 payment/能
DEFINES文件 A 定义符号 Bgateway.ts 定义 processPayment能
OVERRIDESA 覆盖父类方法 BEmailSender.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)

步骤操作耗时
1grep -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 分钟
3AdminDashboard.profileLoader.getProfile() 在列表里——因为 CKG 知道 profileLoader 的类型是 IUserService,而 UserService 实现了 IUserService0(自动包含)
合计3 分钟 + 0 个遗漏

时间从 45 分钟降到 3 分钟,准确率从"可能漏"变成"保证不漏静态可分析的调用"。


三种方式的完整对比

代码理解能力对比

维度grepEmbedding 向量检索CKG 调用图代价 / 局限
原理字符串匹配语义向量距离语法解析 + 图遍历—
找到直接调用方能(名字出现在代码里时)不一定(看语义距离)能,精确动态派发不可追踪
找到接口间接调用不能不一定能反射调用不可追踪
找到多层影响链不能不能能(A→B→C 一次遍历)深度越深,动态调用盲区越可能出现
干扰项注释/mock/字符串/旧代码名字像但没关系的函数无—
需要联网否是(上传算 Embedding)否(本地构建)—
索引速度(10万行)即时(不做索引)分钟到小时级5-15 秒(内部测试数据)首次仍需等待
数据安全不涉及代码片段上传服务端代码不出设备本地模型推理质量低于商业大模型
适合回答的问题"哪些文件包含这个字符串""有没有语义类似的代码""改了这里还要改哪里"不提供"语义相似代码"推荐

三种方式各有用处。向量检索在"找类似实现"的场景下很好用——比如你想知道项目里有没有类似的排序逻辑,Embedding 比 grep 好用得多。grep 在配置文件搜索(YAML/JSON 里的字符串引用)上不可替代。

但当你问的是"改了这里还要改哪里"——这是每次修改前最关键的问题——只有调用图能给你一个准确且完整的答案。


各工具用了哪种

工具grepEmbedding调用图"改了这里还影响哪里"怎么回答
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)。如有更新,以各家最新页面为准。