你的代码库不仅仅是文本

为什么关键词和向量搜索都碰到了天花板——以及SCIP、Tree-sitter和框架适配器能构建什么来替代它们。

你的代码库不仅仅是文本
梯形图转SCL | AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo

你收到这个工单:

"修复访问报告屏幕时用户权限的问题。"

你把它交给一个AI编码代理。它搜索代码库,找到包含 permissionuserreportauthorization 的文件,阅读几个,形成假设,然后开始编写代码。

它会比你希望的更经常地自信地出错。原因如下。

权限逻辑不在UI所在的位置。屏幕调用一个钩子。钩子调用一个API客户端。客户端访问一个控制器。控制器调用一个服务。服务依赖于一个单独的授权组件。

注意步骤3和4发生了什么:这些文件根本不包含"permission"这个词。 它们使用自己的本地词汇。关键词搜索无法到达它们——正如我们将看到的——语义搜索也很吃力,因为它也是基于单个块的含义匹配,而不是基于它们之间的连接

看起来像一个bug的东西实际上是分布在应用程序中的一系列关系。这提出了整个架构存在的问题:

如果我们给AI代理的不仅仅是代码,而是代码如何连接的结构化地图,它能做得更好吗?

1、核心思想

不是让模型在每次查询时重新发现代码库的结构,而是一次性确定性地提取该结构,并将其存储为图。

不是"这里有20个提到权限的文件。" 而是:

ReportsScreen ──uses──▶ useReportAccess ──calls──▶ reportsApi.fetch
      └──requests──▶ GET /api/reports ──handled_by──▶ ReportsController
                                  └──calls──▶ AuthorizationService
ReportsScreen ──uses──▶ useReportAccess ──calls──▶ reportsApi.fetch
      └──requests──▶ GET /api/reports ──handled_by──▶ ReportsController
                                  └──calls──▶ AuthorizationService

这不是搜索结果。它是一张地图。地图是代理可以有意导航而不是猜测的东西。

构建这张地图需要三个工具,因为没有一个工具能看到全貌。

2、架构

三个分析器在代码库上运行,它们的输出合并成一个连贯的图,该图进入一个图数据库,在那里关系可以双向遍历。

让我们逐一介绍每个部分——它做什么,它不能做什么,以及为什么这个差距很重要。

3、组件1 — SCIP:符号层

SCIP(源代码智能协议)是代码智能数据的标准格式:符号、定义、引用以及它们之间的关系。它与IDE用于转到定义的信息属于同一类。

3.1 示例

一个文件定义了一个函数:

// utils/cart.ts
function calculateTotal(items) {
  return items.reduce((total, item) => total + item.price, 0);
}
// utils/cart.ts
function calculateTotal(items) {
  return items.reduce((total, item) => total + item.price, 0);
}

另一个使用它:

// services/CartService.ts
const total = calculateTotal(cartItems);
// services/CartService.ts
const total = calculateTotal(cartItems);

文本搜索给你的是: "calculateTotal 出现在2个文件中"——加上代码库中的每个注释、字符串字面量和类似命名的函数。

SCIP给你的是:

CartService ──calls──▶ calculateTotal
CartService ──calls──▶ calculateTotal

关键的区别:SCIP解析了它。它遵循导入和符号表。它不是基于名称进行模式匹配,所以不会被不同模块中同名的函数愚弄。

3.2 SCIP看不到什么

SCIP不知道JSX是什么。对SCIP来说,React组件只是一个函数。<Button onClick={handleApply}> 是无意义的语法。它不知道HTTP路由是什么,控制器是什么,或者 fetch() 调用有什么特别之处。

这不是缺陷——这是范围。这就是第二个工具的用武之地。

4、组件2 — Tree-sitter:语法层

Tree-sitter 将源代码解析为具体语法树,因此不是将代码视为文本:

if (user.isAdmin) { showAdminPanel(); }
if (user.isAdmin) { showAdminPanel(); }

你可以将其作为结构来查询:

IfStatement
 ├── Condition → MemberExpression (user.isAdmin)
 └── Body      → CallExpression   (showAdminPanel)
IfStatement
 ├── Condition → MemberExpression (user.isAdmin)
 └── Body      → CallExpression   (showAdminPanel)

4.1 示例

给定这段React代码:

<Button onClick={handleApply}>Apply Promo</Button>
<Button onClick={handleApply}>Apply Promo</Button>

Tree-sitter生成类似这样的内容:

{ "match": "JSXAttribute", "element": "Button",
  "prop": "onClick", "value": "handleApply", "line": 12 }
{ "match": "JSXAttribute", "element": "Button",
  "prop": "onClick", "value": "handleApply", "line": 12 }

给定一个API调用:

fetch("/api/cart/promo", { method: "POST" })
{ "match": "CallExpression", "callee": "fetch",
  "url": "/api/cart/promo", "method": "POST", "line": 43 }
fetch("/api/cart/promo", { method: "POST" })
{ "match": "CallExpression", "callee": "fetch",
  "url": "/api/cart/promo", "method": "POST", "line": 43 }

4.2 Tree-sitter不能做什么

仔细看看这些输出。它们是原始模式匹配,不是关系。Tree-sitter可以告诉你存在一个名为 onClick 的JSX属性。它不知道这暗示了一个用户触发的执行路径,或者 /api/cart/promo 对应于一个完全不同的代码库中用不同语言编写的控制器方法。

模式不是含义。这是第三个工具的工作。

5、组件3 — 框架适配器:含义层

这是大多数人跳过的组件,它正是应用程序架构实际存在的地方

通用语言索引器理解 functionclassimport。但实际应用程序是由语言本身一无所知的概念构建的:路由控制器组件中间件依赖注入。这些约定承载着架构含义。

框架适配器是一个小翻译器,将约定转换为关系:

5.1 示例

React适配器接受Tree-sitter的原始匹配:

{ "match": "JSXAttribute", "prop": "onClick", "value": "handleApply" }
{ "match": "JSXAttribute", "prop": "onClick", "value": "handleApply" }

并且——因为它知道 onClick 意味着什么——发出一个真实的边,附加到SCIP已经解析的实际函数节点:

{ "source": "component:Button", "target": "fn:handleApply",
  "type": "EVENT_HANDLER", "provenance": "FRAMEWORK_ADAPTER" }
{ "source": "component:Button", "target": "fn:handleApply",
  "type": "EVENT_HANDLER", "provenance": "FRAMEWORK_ADAPTER" }

同时ASP.NET适配器读取:

[HttpPost("/api/cart/promo")]
public IActionResult ApplyPromo(...) { ... }
[HttpPost("/api/cart/promo")]
public IActionResult ApplyPromo(...) { ... }

并为 POST /api/cart/promo 创建一个 APIEndpoint 节点,以及一个指向控制器方法的 HANDLED_BY 边。

这就是收获: 前端适配器生成了一个指向 POST /api/cart/promoCALLS_API 边。后端适配器生成了一个具有相同标识的 APIEndpoint 节点。它们连接——图现在跨越了两种不共享一行代码的语言。

这个链接是任何文本搜索和任何嵌入模型都无法自行找到的东西。

6、把它们放在一起:三个工具,三个盲点

分工很清晰:

  • SCIP 知道什么引用什么——精确地,通过解析
  • Tree-sitter 知道代码具有什么形状——包括SCIP忽略的东西
  • 适配器 知道这些形状在给定框架中意味着什么

没有一个是可选的。去掉SCIP,你的调用边就变成了猜测。去掉Tree-sitter,你就看不到UI层。去掉适配器,你就有了一个没有应用程序架构的语言级图。

7、一次按钮点击,端到端追踪

以下是三个工具在单个链上的工作,这样你就可以准确看到哪个工具解析了哪个链接:

参考源代码:

// CheckoutSummary.tsx
<Button onClick={handleApply}>Apply Promo</Button>
function handleApply() { cartService.applyPromo(code); }
// CartService.ts
function applyPromo(code) {
  return fetch("/api/cart/promo", { method: "POST", body: code });
}
// CartController.cs
[HttpPost("/api/cart/promo")]
public IActionResult ApplyPromo(string code) { ... }
// CheckoutSummary.tsx
<Button onClick={handleApply}>Apply Promo</Button>
function handleApply() { cartService.applyPromo(code); }
// CartService.ts
function applyPromo(code) {
  return fetch("/api/cart/promo", { method: "POST", body: code });
}
// CartController.cs
[HttpPost("/api/cart/promo")]
public IActionResult ApplyPromo(string code) { ... }

六跳,两种语言,四个文件,第一个和最后一个之间零个共享关键词。图将它们全部连接起来。

8、聚合实际上如何工作

三个工具产生三种不同的输出格式。将它们合并成一个连贯的图本身就是一步,而且不仅仅是合并。

有两个部分值得仔细看看。

8.1 去重

SCIP将 applyPromo 视为函数符号。Tree-sitter将其视为语法节点。适配器附加了一条边。如果不进行去重,你会得到同一个函数出现在三个标签下三次——遍历会静默地在副本之间分裂。

8.2 通过优先级解决冲突

有时工具不同意。Tree-sitter可能将某个东西分类为 Function,而React适配器说是 Component。不要用判断来解决——使用固定表:

RENDERS relationships :  Framework Adapter  >  SCIP  >  Tree-sitter
CALLS relationships   :  SCIP  >  Tree-sitter  >  heuristic
CONTAINS (file)       :  Filesystem  >  everything else
RENDERS relationships :  Framework Adapter  >  SCIP  >  Tree-sitter
CALLS relationships   :  SCIP  >  Tree-sitter  >  heuristic
CONTAINS (file)       :  Filesystem  >  everything else

关键是:失败的分类不会被丢弃,它被保留为元数据。当图中的某些东西后来看起来不对时,那段历史是十分钟调查和重新运行整个管道以弄清楚发生了什么之间的区别。

8.3 输出什么

每个节点和边都带有它的来源:

{
  "source": "fn:handleApply",
  "target": "fn:applyPromo",
  "type": "CALLS",
  "provenance": "SCIP",
  "evidence": [{ "filePath": "src/checkout/CheckoutSummary.tsx", "startLine": 9 }]
}
{
  "source": "fn:handleApply",
  "target": "fn:applyPromo",
  "type": "CALLS",
  "provenance": "SCIP",
  "evidence": [{ "filePath": "src/checkout/CheckoutSummary.tsx", "startLine": 9 }]
}

那个 provenanceevidence 对使图可审计。你可以指向任何关系并问"为什么系统相信这个?"并得到一个真实的答案:一个文件、一行和解析它的工具。

如果模型告诉你"组件A调用服务B",那是一个假设。如果图告诉你,你可以要求收据。

9、这比向量数据库更好吗?

这是每个人都会问的问题,诚实的答案是这是一个错误的问题。

向量搜索在一个图无能为力的事情上确实非常出色:桥接词汇。工单说"登录屏幕不断将人们登出。" 代码将其称为 idleExpiryMs。图中没有任何边连接这两个短语——但嵌入模型立即匹配它们。

图在向量搜索结构性无法做到的事情上非常出色:跟随连接。问"这个组件最终调用了哪个API?"任何语义相似性都无济于事。那是遍历,不是相似性分数。

简单地说:

向量层回答 "什么代码与此概念相关?" ,图回答 "该代码如何与其他所有内容连接?"。

两者都不是对方的替代品,这直接指向了值得构建的架构。

10、混合架构

这个见解是分工,它简单到可以用一句话陈述:

语义搜索找到起点。图决定下一步去哪里。

走过一个真实的工单——"产品过滤返回不正确的结果。"

步骤1——语义检索。 工单没有命名任何文件。代码库上的向量搜索显示候选:ProductListProductFilterfilterProducts。这是唯一可以将业务语言桥接到代码标识符的层。

步骤2——图遍历。 现在我们有了一个具体的节点,所以我们切换工具并遍历:

ProductList → useProductFilter → fetchProducts
            → GET /products → ProductController → ProductService
ProductList → useProductFilter → fetchProducts
            → GET /products → ProductController → ProductService

一个模糊匹配变成了一个精确、完整的依赖链——跨越了向量搜索永远无法跨越的前端/后端边界。

步骤3——有针对性的源检索。 代理不是用前5个语义相似的块填充上下文窗口,而是读取追踪路径上的特定文件

步骤4——基于证据的推理。 有了实际的链在眼前,代理发现前端发送 category,而控制器期望 categoryId。不是一个听起来合理的猜测——而是一个每个跳跃都可以追溯到文件和行的结论。

11、为什么这个顺序很重要

两个层在不同的时刻做不同的工作,交换它们是行不通的。

没有语义搜索的遍历没有入口点——你需要已经知道从哪个节点开始,这正是你收到模糊工单时所不知道的。

没有遍历的语义搜索返回一组分散的、单独合理的文件,无法判断哪些文件实际上相互连接。

它们一起产生渐进式缩小

Large repository
   ↓  semantic search — bridges vocabulary
Relevant area
   ↓  graph traversal — follows connections
Exact dependency chain
   ↓  source retrieval
A handful of files
   ↓
Focused agent context
Large repository
   ↓  semantic search — bridges vocabulary
Relevant area
   ↓  graph traversal — follows connections
Exact dependency chain
   ↓  source retrieval
A handful of files
   ↓
Focused agent context

每一步都使用证据而不是猜测来缩小搜索空间。

12、图的真正工作

很容易认为图的价值在于它回答问题。不完全是这样。

图的真正工作是帮助代理决定下一步去哪里。

在一个20,000个文件的代码库中,一个盲目搜索的代理在无关代码上浪费了巨大的精力——更糟的是,它无法区分"没有结果"和"答案在三跳之外"。图将探索从猜测转变为导航。

13、这不能解决什么

诚实地面对限制比推销更重要。静态索引无法完美捕获运行时行为,真正的差距包括:

  • 动态代码和反射
  • 生成的代码
  • 运行时依赖注入
  • 动态构造的API路径
  • 不常见堆栈的不完整语言工具
  • 具有不寻常布局的monorepos

图永远只与你成功提取的内容一样好。所以声明不是*"我们完美地建模了代码库。"* 而是:

"我们构建了一个确定性结构层,为代理提供比搜索本身更丰富的架构上下文——其中每个声明都可以追溯到文件和行。"

14、最终要点

探索从一个简单的问题开始——我们如何让AI代理搜索代码库?——变成了一个更好的问题:

我们如何给AI代理一个软件系统实际如何工作的结构化表示?

代码库包含文本、语法、符号、引用、依赖、调用、API、框架约定和架构。没有单一的索引方法能同样好地捕获所有这些:

  • 向量嵌入——语义检索,桥接词汇
  • SCIP——确定性符号和引用事实
  • Tree-sitter——语法结构
  • 框架适配器——应用程序级含义
  • 图数据库——存储和遍历关系
  • 代理——推理组合证据

教训不是"图击败向量。" 而是:

代码既是语义的又是关系的,所以代码智能必须同时推理两者。

知道代码在哪里只是开始。有趣的问题是你的代理是否理解什么依赖什么,什么调用什么,以及如果某些东西改变什么可能会中断。

这是值得追求的转变:从代码检索结构化代码智能


原文链接:Your Codebase Is More Than Text: Building a Structural Knowledge Graph for AI Code Understanding

汇智网翻译整理,转载请标明出处