多文件搜索的深度对比:六款工具的检索策略
利益声明:本文作者参与了 wescode 的开发。文中涉及 wescode 的技术描述基于内部测试数据;涉及其他工具的描述基于各家公开文档和社区反馈。
当你对 AI 说:"帮我找到所有调用 processPayment 的地方"——然后呢?
不同工具的处理方式天差地别:有的做文本匹配,有的做语义搜索,有的查调用图,有的直接 grep。这些差异直接决定了:你拿到的结果是精确的还是充满噪音的,是完整的还是遗漏了关键调用方的。
一、测试场景:一次跨 5 个文件的参数改动
假设你的项目里有一条支付调用链,跨越 5 个文件。你需要给 processPayment 加一个 currency 参数——问题是:到底有几个地方需要跟着改?
文件 1:接口定义
// src/gateways/PaymentGateway.ts
export interface PaymentGateway {
processPayment(amount: number): Promise<PaymentResult>
refundPayment(paymentId: string): Promise<void>
}
文件 2:实现类
// src/gateways/StripeGateway.ts
import type { PaymentGateway, PaymentResult } from './PaymentGateway'
export class StripeGateway implements PaymentGateway {
constructor(private stripe: Stripe) {}
async processPayment(amount: number): Promise<PaymentResult> {
const charge = await this.stripe.charges.create({
amount,
currency: 'usd', // ← 当前硬编码,你想改成参数传入
})
return { id: charge.id, status: charge.status }
}
async refundPayment(paymentId: string): Promise<void> {
await this.stripe.refunds.create({ charge: paymentId })
}
}
文件 3:Service 调用 gateway
// src/services/OrderService.ts
import type { PaymentGateway } from '../gateways/PaymentGateway'
export class OrderService {
constructor(private gateway: PaymentGateway, private repo: OrderRepo) {}
async checkout(input: CheckoutInput): Promise<Order> {
const payment = await this.gateway.processPayment(input.amount) // ← 调用点 1
return this.repo.save({ ...input, paymentId: payment.id })
}
}
文件 4:CronJob 通过接口间接调用
// src/cron/SettlementJob.ts
import type { PaymentGateway } from '../gateways/PaymentGateway'
export class SettlementJob {
constructor(private gateway: PaymentGateway, private repo: OrderRepo) {}
async execute() {
const pending = await this.repo.findPendingOrders()
for (const order of pending) {
await this.gateway.processPayment(order.remainingAmount) // ← 调用点 2(接口调用)
}
}
}
完整调用链:
CheckoutController.handle()
→ OrderService.checkout()
→ PaymentGateway.processPayment() ← 你要改的接口方法
SettlementJob.execute()
→ PaymentGateway.processPayment() ← 通过同一接口间接调用
StripeGateway.processPayment() ← 接口实现,参数签名必须一起改
你至少需要改 4 个地方:接口定义、StripeGateway 实现、OrderService 的调用、SettlementJob 的调用。漏一个 = 运行时类型错误。
二、补充场景:Python FastAPI 的跨文件依赖注入
TypeScript 的 implements 关系已经够难搜了。在 Python FastAPI 项目里,依赖注入让调用链更加隐蔽:
# app/services/payment_service.py
from app.repositories.payment_repo import PaymentRepo
class PaymentService:
def __init__(self, repo: PaymentRepo):
self.repo = repo
async def process_payment(self, amount: float, user_id: str) -> dict:
record = await self.repo.create_payment(amount, user_id)
return {"payment_id": record.id, "status": "pending"}
# app/dependencies.py
from app.services.payment_service import PaymentService
from app.repositories.payment_repo import PaymentRepo
def get_payment_service() -> PaymentService:
return PaymentService(repo=PaymentRepo()) # ← 依赖注入的工厂函数
# app/routers/orders.py
from fastapi import APIRouter, Depends
from app.dependencies import get_payment_service
from app.services.payment_service import PaymentService
router = APIRouter()
@router.post("/checkout")
async def checkout(
body: CheckoutRequest,
svc: PaymentService = Depends(get_payment_service) # ← FastAPI DI
):
result = await svc.process_payment(body.amount, body.user_id) # ← 调用点
return result
当你想给 process_payment 加一个 currency 参数,grep process_payment 会返回什么?——除了真正的调用点,还有 get_payment_service 工厂函数里的类名引用、测试里的 mock、文档字符串,全部混在一起。而 Depends(get_payment_service) 这种 FastAPI 的依赖注入魔法,grep 完全看不出 svc 就是 PaymentService 的实例。

三、三种工具在这个任务上的搜索深度
Cursor:Embedding 语义搜索
Cursor 把整个代码库切片上传算向量,按语义相似度返回结果。它搜 processPayment 返回的前 5 个命中:
| 排名 | 文件 | 命中原因 | 是你要改的吗 |
|---|---|---|---|
| 1 | StripeGateway.ts | 名字完全匹配 | ✅ 接口实现 |
| 2 | PaymentGateway.ts → refundPayment | 同一个接口,名字像 | 否(兄弟方法) |
| 3 | PaymentHistory.ts | 名字里有 Payment | 否(无调用关系) |
| 4 | BillingService.ts | 语义相关(都和钱有关) | 否(无调用关系) |
| 5 | OrderService.ts | checkout 里调了它 | ✅ 调用方 |
找到 2 个,漏了 1 个关键调用方。 SettlementJob 没出现在前 5——因为"结算任务"和"处理支付"的语义距离比"退款"更远。而且日志行 logger.info("processPayment failed") 可能挤掉真正的调用方。
Claude Code:grep 暴力搜索
Claude Code 在终端跑 rg "processPayment" --type ts,返回 19 个结果:
| 类型 | 数量 | 是你要改的吗 |
|---|---|---|
| 接口声明 + 实现定义 | 3 | ⚠️ 部分是(实现要改,声明也要改) |
直接调用 this.gateway.processPayment(...) | 2 | ✅ 真正的调用方 |
测试 mock mockGateway.processPayment.mockResolvedValue(...) | 6 | 测试代码 |
注释和文档 // 重构 processPayment 时... | 3 | 注释 |
日志字符串 logger.info("processPayment failed") | 2 | 字符串 |
类型引用 type ProcessPaymentArgs = Parameters<...> | 2 | ⚠️ 可能需要改 |
旧版函数 processPaymentLegacy | 1 | 不同函数 |
19 个结果里真正需要改的有 4-5 个。你需要手动排除 14 个干扰项——逐个点开文件、看上下文、判断是不是真调用。这就是"搜索用 5 秒,筛选用 20 分钟"的来源。
而且 grep 有一个根本性盲区:它能找到 this.gateway.processPayment() 是因为方法名相同。如果某天接口方法改名了,或者代码里写的是 this.pay.process()(别名调用),grep 就彻底搜不到了。
wescode CKG:调用图一键返回
CKG 基于 tree-sitter 语法解析建好的调用关系,直接返回:
| 调用方 | 关系类型 | 文件 |
|---|---|---|
OrderService.checkout() | CALLS | src/services/OrderService.ts |
SettlementJob.execute() | CALLS(经 IMPLEMENTS 解析) | src/cron/SettlementJob.ts |
StripeGateway.processPayment() | IMPLEMENTS | src/gateways/StripeGateway.ts |
3 个结果,3 个真正需要改的地方。零干扰。
SettlementJob 能被找到,是因为 CKG 知道 this.gateway 的类型是 PaymentGateway,StripeGateway 实现了 PaymentGateway(IMPLEMENTS 边),所以 this.gateway.processPayment() 就是在调用 PaymentGateway.processPayment()。grep 做不到,Embedding 也做不到——只有语法级的类型解析才能推导出这种间接关系。
Before / After 对比:grep 搜索 vs CKG 搜索
| 维度 | grep / rg 搜索 | CKG 调用图查询 |
|---|---|---|
| 耗时 | 搜索 5 秒 + 人工筛选 15-25 分钟 | 查询 < 200ms(内部测试数据),零人工筛选 |
| 返回结果数 | 19 个 | 3 个 |
| 噪音率 | ~74%(14/19 是干扰项) | 0% |
| 遗漏率 | 接口实现、覆写方法可能漏掉 | 静态可分析的关系 0 遗漏 |
| 能否追踪间接调用 | 否(只看字符串匹配) | 是(递归遍历调用链) |
四、CKG 的多层遍历是怎么一层层追出去的

当你在 wescode 里选中 PaymentGateway.processPayment,CKG 的查询分三步完成:
第一步:从改动点出发,走 CALLS 边找直接调用方。
查询 call_edges 表里所有 target_symbol = 'PaymentGateway.processPayment' 的行。直接命中 OrderService.checkout() 和 SettlementJob.execute()——它们的代码里都写了 this.gateway.processPayment(...)。
第二步:走 IMPLEMENTS 边找接口实现方。
查询 implements_edges 表里所有实现了 PaymentGateway 的类。命中 StripeGateway——它的 processPayment 方法签名必须和接口一致,改接口就必须改它。如果还有 PayPalGateway、AlipayGateway,也会一并返回。
第三步:递归走 CALLS 边找上层调用方。
从第一步找到的 OrderService.checkout() 继续向上查——谁在调用 checkout()?命中 CheckoutController.handle()。这条链一直递归到入口层(Controller / CronJob / 事件处理器),直到没有更上层的调用方。

三步查询在本地 SQLite 上执行,总耗时毫秒级。一次查询,完整的影响链,零干扰项。
五、搜索策略对比表

| 维度 | Cursor | Copilot | Claude Code | wescode | 通义灵码 | Trae |
|---|---|---|---|---|---|---|
| 搜索方法 | Embedding 语义搜索 | LSP + GitHub 搜索 | grep/rg 暴力搜索 | CKG 调用图 + FTS5 | IDE 搜索 + 模型推测 | Agent 搜索 + IDE |
| 区分调用 vs 字符串 | ✅ LSP 区分 | 否(靠模型后处理) | ✅ tree-sitter 语法级 | |||
| 间接调用追踪 | 不支持 | 不支持 | 不支持 | 支持(递归查询) | 不支持 | 不支持 |
| 模糊/自然语言查询 | ✅ 强项 | ⚠️ 有限 | ⚠️ 靠模型构造 | ⚠️ FTS5 文本搜索 | ⚠️ 模型推测 | ⚠️ 模型推测 |
| 大仓索引速度(10万行) | 分钟到小时级 | 取决于 LSP | 即时(不索引) | 5-15 秒(内部测试数据) | 取决于 IDE | 取决于 IDE |
| 数据存储 | Cursor 数据库 | 本地 + GitHub | 不存储 | 本地 SQLite | IDE 本地 | IDE 本地 |
六、CKG 的六种关系边:每种边的真实代码例子
CKG 通过 10-Pass 索引管线从 tree-sitter AST 中提取六种关系。每种关系对应一类你在重构中必须追踪的依赖:
1. CALLS — 直接调用
// OrderService.ts
async checkout(input: CheckoutInput) {
const payment = await this.gateway.processPayment(input.amount)
// ^^^^^^^^^^^^^^^^ CALLS 边
}
这是最常见的关系。CKG 记录"谁调用了谁",改 processPayment 的签名时,所有 CALLS 边的源头都需要跟着改。
2. IMPLEMENTS — 接口实现
// StripeGateway.ts
export class StripeGateway implements PaymentGateway {
// ^^^^^^^^^^^^^^^^^^ IMPLEMENTS 边
async processPayment(amount: number) { /* ... */ }
}
grep 搜不到这种关系。 你搜 processPayment 能找到 StripeGateway 里的同名方法,但搜不出它是因为实现了 PaymentGateway 接口才必须有这个方法。CKG 从 implements 关键字直接提取。
3. OVERRIDES — 方法覆写
// PremiumPaymentProcessor.java
public class PremiumPaymentProcessor extends BasePaymentProcessor {
@Override
public PaymentResult processPayment(BigDecimal amount) {
// ^^^^^^^^^^^^^^^^ OVERRIDES 边:覆写了父类方法
applyDiscount(amount);
return super.processPayment(amount.multiply(DISCOUNT_RATE));
}
}
Java 项目里 @Override 满天飞。grep 搜 processPayment 能找到这个方法,但不知道它和父类的 processPayment 有覆写关系——你改了父类签名不改子类,编译器会报错,但 AI 如果不知道这层关系,就不会主动帮你一起改。
4. IMPORTS — 模块导入
// OrderService.ts
import type { PaymentGateway } from '../gateways/PaymentGateway'
// ^^^^^^^^^^^^^^ IMPORTS 边
IMPORTS 边帮助 CKG 理解模块间的依赖方向。当你重命名一个导出符号时,所有 IMPORTS 边的源头都需要更新。
5. DEFINES — 符号定义
# app/services/payment_service.py
class PaymentService:
async def process_payment(self, amount: float, user_id: str) -> dict:
# ^^^^^^^^^^^^^^^ DEFINES 边:此文件定义了 process_payment
...
DEFINES 边是 CKG 的"定义跳转"基础——从任何一处调用点,沿 DEFINES 边直接跳到定义处。
6. EXTENDS — 类继承
// PremiumOrder.ts
export class PremiumOrder extends Order {
// ^^^^^^^ EXTENDS 边
calculateTotal(): number {
return super.calculateTotal() * 0.9 // 继承父类方法并扩展
}
}
EXTENDS 边和 OVERRIDES 边配合使用:EXTENDS 说"A 继承了 B",OVERRIDES 说"A 覆写了 B 的某个方法"。两者结合,CKG 能完整追踪继承树上的所有影响。
grep 能覆盖几种?
| 关系 | grep 能找到吗 | 说明 |
|---|---|---|
| CALLS | ✅ 名字出现时能找到 | 但无法区分调用 vs 字符串 |
| IMPLEMENTS | 搜不到 | 这是 grep 最大的盲区 |
| OVERRIDES | 搜不到 | grep 不知道覆写关系 |
| IMPORTS | ✅ | import 语句本身是文本 |
| DEFINES | ✅ | 定义处包含方法名 |
| EXTENDS | ✅ | extends 关键字是文本 |
grep 在 6 种关系里有 4 种能搜到,但 IMPLEMENTS 和 OVERRIDES 完全搜不到——而这两种恰恰是 TypeScript / Java 项目里密度最高的关系。接口抽象越多、依赖注入越重的项目,grep 的盲区越大。
CKG 追不到的场景
| 场景 | 为什么追不到 | 替代方案 |
|---|---|---|
动态派发 obj[methodName]() | 运行时才确定调用目标 | grep 兜底 |
| 配置文件引用 YAML/JSON 里的字符串 | tree-sitter 不解析非代码文件 | grep 兜底 |
| 消息队列 生产者 → Redis/Kafka → 消费者 | 没有直接调用关系 | grep + 文档 |
事件总线 eventBus.emit('payment') → eventBus.on('payment') | 字符串匹配不是调用 | grep 兜底 |
| 元编程 / 代码生成 | CKG 只分析源码,不分析生成物 | 看生成后的代码 |
覆盖率在大多数业务项目上在 85-95%(内部测试数据)——因为大多数代码的调用关系是静态可分析的。剩下的 5-15% 靠 FTS5 全文搜索和 grep 兜底。不完整但精确的结果,比完整但充满干扰的结果有用得多。
七、索引性能
CKG 所有索引在本地完成,存储在本地 SQLite。不上传任何代码到服务器。
| 项目规模 | 首次索引 | 增量更新(文件保存后) | 索引大小 |
|---|---|---|---|
| 1 万行 | < 2 秒 | < 50ms | ~1MB |
| 10 万行 | 5-15 秒(内部测试数据) | 100-200ms | ~10MB |
| 50 万行 | 30-60 秒(内部测试数据) | 200-500ms | ~50MB |
对比 Cursor 的 Embedding 索引:Cursor 需要把代码分块上传到服务端算向量,受 Embedding API 吞吐量限制。CKG 是纯本地 CPU 计算,不受网络和 API 限速约束。
八、实战选型建议
| 你的主要需求 | 推荐工具 | 原因 |
|---|---|---|
| "谁调用了 X" 精确查询 | wescode(CKG) | 调用图遍历,语法级精度 |
| "找到和 X 相关的代码" 模糊查询 | Cursor(Embedding) | 语义理解强,自然语言查询好 |
| 不需要预索引,即时搜索 | Claude Code(grep) | 无索引开销,即开即搜 |
| 强类型语言的精确引用 | Copilot(LSP) | TypeScript/Java 的类型级精度 |
| 修改函数后的影响分析 | wescode(CKG 递归查询) | 唯一能追踪间接调用链的方案 |
总结:如果你的工作流以重构和影响分析为主——CKG 调用图是独一无二的能力。Embedding 在"找类似代码"场景下更自然,grep 在配置文件搜索上不可替代。三种策略各有用处,但当你问的是**"改了这里还要改哪里"**——这是每次重构前最关键的问题——只有调用图能给你一个准确且完整的答案。
FAQ
Q:CKG 对动态语言(Python / JavaScript)准确吗?
A:静态分析对 Python 的 duck typing、JavaScript 的 obj[methodName]() 确实有盲区。但大多数业务代码的调用关系是静态可分析的——Service 调 Repository、Controller 调 Service,这些都是确定性的。覆盖率 85-95%,比向量检索"名字像就塞进来"准确得多。
Q:能不能同时用 Embedding + 调用图?
A:技术上可以。但两种结果混在一起展示时,用户更难分辨哪些是"真有调用关系"、哪些是"名字像"。wescode 选择了只做调用图——宁可不给"类似代码"推荐,也不让干扰项混进影响分析结果。
Q:wescode 还是 VS Code 吗?
A:wescode 是 VS Code 的开源 Fork。你的扩展、快捷键、主题、设置全部保留,零迁移成本。CKG 是在 VS Code 基座上加的能力层,不是另一个编辑器。
Q:CKG 支持哪些语言? A:CKG 基于 tree-sitter 做语法解析,支持 TypeScript、JavaScript、Python、Java、Rust 等主流语言。六种关系边在不同语言中的提取精度略有差异——强类型语言(TypeScript、Java)的 IMPLEMENTS / OVERRIDES 边最精确,动态语言(Python、JavaScript)的 CALLS 边覆盖率约 85-90%。