那段时间我刚好在做一个遗留系统的重构评估。代码仓库不大,但调用关系很绕:订单状态变更会触发库存锁定、优惠券核销、消息推送,中间还隔了两个 RPC 服务。我把仓库里的 Java 文件按函数切块、向量化,然后接上一个常规的 RAG 流程,准备让大模型回答“如果我改了订单状态流转,可能影响哪些下游”。结果很直接:单文件之内的问答基本靠谱,一旦涉及跨文件、跨服务的调用链,答案就开始“猜”。有的回答漏掉了中间环节,有的把调用方向搞反了。问题不在模型,而在检索。
代码不是线性文本,它是图。函数之间互相调用,类之间有继承,接口有实现,模块之间有依赖。把这些关系硬塞进普通的文本切片里,等于把一张地铁线路图拆成几十张局部照片,再让语言模型根据模糊的相似度去猜下一站在哪。看到vitali87/code-graph-rag这个项目名时,我第一反应是:这条路可能对。它的关键词不是 RAG,而是 graph。
这篇文章想说的不是某个具体工具怎么安装,而是把代码图谱 RAG 这一类方案真正解决了什么问题、怎么落地、会遇到哪些坑讲清楚。我还会给出一条从零开始的最小验证路径,你不需要等一个完美框架,可以先在一个小仓库上把链路跑通。
1. 为什么代码 RAG 不能直接套文档问答的套路
1.1 文档和代码的信息组织方式完全不同
常规的 RAG 方案,核心操作是“切块 + 向量化 + 相似度召回”。这对文档类内容很合理,因为文档本身就是线性的:一个段落接着一个段落,章节之间有自然的顺序。就算切块切得不完美,上下文丢失也是有限的。
代码不一样。一个函数往往只有几十行,但它依赖另一个文件里的类,那个类又继承了一个抽象基类,抽象基类的某个方法在运行时才绑定到具体实现。如果按文件切块,函数所在文件并不包含被调用的实现;如果按固定长度切块,还可能把一个方法的头和尾切进不同的块里。你最终喂给模型的内容,在结构上是残缺的。
我见过不少团队把“代码 RAG”做成了“代码文档 RAG”,就是把 README、注释、接口文档这些文本资料喂给模型。对新人了解项目有一定帮助,但一旦问题涉及到真实代码路径,比如“这个接口的请求体在哪个 DTO 里做了字段校验”“这个定时任务最后会落到哪张表”,文本索引就很难给出准确答案。因为答案藏在调用链里,不在单个文本块里。
1.2 文本相似度匹配无法理解“经过”
代码问题里很常见的一类表达是:“从入口到数据库,中间经过了哪些处理?”这里的关键词是“经过”。它表达的是一个路径,而不是一个语义相似的片段。
检索系统在匹配“入口”时通常没问题,因为入口函数签名和问题里的关键词天然相似。检索系统在匹配“数据库调用”时一般也没问题,因为 SQL 语句和表名会出现。真正难的是把中间那些“没有直接提到数据库、也没有直接提到入口”的节点找出来。这些节点可能是一个参数校验方法、一个日志拦截器、一个状态机转换函数。它们的文本内容和用户问题里的词面没有重叠,但它们在调用路径上。
这就是纯向量召回的硬伤:相似度计算是文本层面的,不是结构层面的。你可以提高 topK,把更多候选块塞进去,但 TopK 变大之后,上下文被不相关的内容占满,模型反而更容易被误导。你需要的不是“更多片段”,而是“一条路径”。
1.3 按问题类型决定检索策略
我习惯把代码问答分成三类,它们对检索结构的要求完全不同:
| 问题类型 | 典型问法 | 适合的检索方式 |
|---|---|---|
| 单点理解 | 这个函数的参数是什么?返回什么? | 文本向量召回,定位到函数或类 |
| 局部关系 | 这个方法被哪些地方调用?这个类继承了谁? | 调用图 / 继承图遍历 |
| 链路分析 | 从 A 到 B 经过了哪些步骤?这个接口影响哪些下游? | 图遍历 + 关键路径筛选 |
第一类问题,普通 RAG 就能解决;第二类问题,需要有一张图;第三类问题,不仅需要图,还需要图上的路径搜索能力。code-graph-rag这类项目真正想解决的问题,正是后两类。
如果把代码图谱 RAG 当成一种“给所有代码问答案”的通用工具,你会觉得它很重。但如果你面对的问题是跨模块影响分析、重构风险评估、历史代码逻辑梳理,那它就是必要的。因为它不是在找“相似的文字”,而是在找“相关的结构”。
1.4 一个具体例子说明差别
举个简单的例子。假设有这么一段 Java 代码:
OrderService.cancel()调用RefundService.refund()RefundService.refund()调用WalletClient.deduct()WalletClient.deduct()是远程 RPC 调用
用户问:“取消订单之后,钱包余额会被扣掉吗?”
文本检索时,系统可能会召回WalletClient.deduct(),因为“钱包余额”和wallet有语义关联。但 OrderService 这个入口可能会被漏掉,或者 RefundService 这个中间桥梁会被漏掉。模型拿到一个孤立的deduct方法,只能猜它是不是在取消订单时被调用。
但如果图里存在OrderService.cancel -> RefundService.refund -> WalletClient.deduct这条路径,检索系统会先通过问题召回WalletClient节点,然后沿着调用边向上回溯,找到RefundService,再找到OrderService。模型拿到的是整条链路,它才能回答“会,但中间还经过了退款服务,并且最终是一个远程调用。”这类答案靠文本匹配是给不出来的。
2. code-graph-rag 类项目到底在做一件什么事
2.1 项目名里的三层含义
vitali87/code-graph-rag这个项目名,拆开看就是三个词:code、graph、rag。它的核心不是强调“用 RAG 读代码”,而是强调“用图来组织代码,再交给 RAG”。
这不是文字游戏,而是设计路径的不同。普通的代码 RAG 流程是:
代码文本 -> 切片 -> 向量库 -> 召回 -> LLM
代码图谱 RAG 的流程更像:
代码 -> AST -> 图结构(节点 + 边) -> 向量索引 + 图索引 -> 混合检索 -> LLM
差别在于中间多了一个“图”的表示层。图不是一个附加功能,而是检索上下文的组织方式。没有这层图,模型拿到的上下文是“若干块文本”;有了图,模型拿到的上下文是“一棵以关键符号为根节点的关系子图”。
从工程实现角度说,这个项目名称可能对应一个小型实现,也可能是一套实验代码。因为原始资料里没有给出完整文档,我这里不做具体功能断言,只讨论这类项目通常采用的核心设计。如果你想直接使用,建议先看仓库里的 README 和示例数据,再决定是否动手改。
2.2 一个可用的代码图谱至少要有三层
不是所有“图”都适合 RAG。如果只把文件目录结构画成树,那只是表面关系。要支撑链路问答,我建议至少建立三层:
- 文件层:仓库目录、包名、模块边界、构建文件。它负责回答“这个功能在哪个模块”这类粗粒度问题。
- 符号层:函数、类、方法、字段、接口、注解。它负责回答“这个函数被谁调用”“这个类继承自谁”这类细粒度问题。
- 语义层:数据库表、RPC 接口、配置项、外部服务。这一层不一定存在代码文件内部,但它是代码行为的重要终点。比如一个 Service 方法最终操作了哪张表,这个信息需要从 ORM 映射和 SQL 里抽取,再挂到符号节点上。
三层不一定都要完整实现,但符号层是底线。只有文件层没有符号层,图太粗;只有符号层没有语义层,链路到不了数据库和外部依赖,影响分析会断在一个边界上。
2.3 节点和边到底要存什么
代码图谱里的节点,不能简单存“整个文件”。我建议一个节点代表一个可独立理解的代码单元,通常是一个函数、一个方法、一个类。节点上至少要有以下几类信息:
- 符号签名:
public void cancel(Order order) - 所属文件路径和行号,方便溯源
- 注释或文档说明
- 摘要:用一段简短的自然语言描述这个方法做了什么,而不是放完整源码
- 必要的原始代码片段,比如关键实现里的 10-20 行
边则要能表达代码之间的关系:
| 边类型 | 含义 | 示例 |
|---|---|---|
| 调用 | 调用者指向被调用者 | OrderService.cancel->RefundService.refund |
| 继承/实现 | 子类指向父类或接口 | OrderServiceImpl->OrderService |
| 引用 | 类型引用、字段引用 | RefundService类使用了WalletClient |
| 文件包含 | 文件包含哪些符号 | OrderController.java包含cancel方法 |
| 数据流 | 变量的写入和读取 | orderId流向refund请求对象 |
边的重要性不亚于节点。一个节点如果没有边,它在图谱 RAG 里就像一个孤岛。你可以通过向量召回找到它,但无法从它扩展出上下文。所以图构建阶段,抽边的质量基本决定了整个系统的上限。
2.4 为什么是“向量召回入口 + 图遍历扩展路径”
很多人在设计时容易陷入一个误区:有了图,是不是就不需要向量检索了?不是。图检索擅长“从已知节点出发扩展路径”,但不擅长“从自然语言定位起点”。用户问题不会严格等于某个函数名,而是“取消订单后余额会扣吗”这种表达。
所以更合理的策略是混合检索:
- 向量召回负责“定位入口”。用自然语言问题去匹配节点摘要,找到最相关的 3-5 个符号。
- 图遍历负责“扩展上下文”。从这些入口节点出发,沿调用边、继承边向外扩展 1-3 层,取出这条路径上的所有节点。
- 路径排序负责“裁剪上下文”。不是把所有邻居都塞给模型,而是按调用方向和问题相关性排序,去掉无关分支。
- 最后把关键路径上的节点摘要拼成 prompt,交给 LLM。
这个过程很像人查代码:先在 IDE 里搜索一个入口函数,然后不断 “Find Usages” 或 “Go to Definition”,沿着调用链读下去。向量召回是帮你找到第一个文件的,图遍历是帮你跳转到下一个文件的。两者缺一不可。
3. 从零跑通一个代码图谱 RAG:最小可复现流程
3.1 先别贪大,选一个小型仓库试水
我见过很多团队一上来就拿公司几百万行的 monorepo 做实验,结果解析半天、图数据巨大、检索慢,最后项目被砍。做图谱 RAG,第一步应该是找一个几百个文件的开源项目,或者把自己负责的一个小模块独立出来。
我建议选一个你自己熟悉的 Java + Spring 项目,或者 Python + Flask/FastAPI 项目。因为你自己知道正确答案,才能判断检索结果准不准。如果选一个完全不认识的仓库,你很难分辨模型答案里哪些是检索给的、哪些是模型脑补的。
3.2 通用流程:解析、建节点、抽边、向量化、检索
在没有现成工具的情况下,可以用下面这个流程来搭最小系统。它不依赖某个特定框架,标准接口都通用。
第一步:AST 解析和符号提取
用 Tree-sitter、JavaParser、TypeScript compiler API 这类解析器,把每个代码文件解析成 AST。然后遍历 AST,提取:
- 函数/方法定义
- 类/接口定义
- function call 表达式
- 字段声明
- import 依赖
这一步的输出是中间结构,不是最终图。中间结构应该是一个列表:{符号ID, 类型, 名称, 文件, 行号, 源码片段, 调用列表}。
注意:不要直接拿整个文件当节点。节点太粗,会导致一个文件里的多个无关方法被绑在一起,检索时上下文噪声很大。
第二步:生成节点摘要
这一步有两种做法:
- 直接把函数的前 N 行和签名拼起来当摘要,成本低,但信息可能不够。
- 用一个小型 LLM 为每个函数生成一句行为描述,效果更好,但会给构建过程增加时间和成本。
我自己的经验是:先用模板摘要跑通,等基本流程稳定之后,再逐步替换成 LLM 摘要。因为模板摘要足够做链路验证,LLM 摘要的主要价值是提升“向量召回入口”的命中率,但不会挽救一个抽边抽错的项目。
第三步:构建图存储
小规模可以用 NetworkX 或内存里的邻接表;中等规模可以用 Neo4j 或 Memgraph;如果你希望和向量库深度集成,可以选支持图查询的向量数据库,或单独建一个存边的表。
最简结构只需要两张表或两个集合:
- 节点集合:存符号 ID、摘要向量、签名、文件路径、行号、摘要文本。
- 边集合:存起点 ID、终点 ID、边类型、权重。
不要过度设计。先让链路跑通,再迁移到更强壮的存储。
第四步:混合检索和 prompt 编排
查询时:
- 把用户问题向量化,在节点集合中做 topK 相似度召回,K 我建议从 5 开始。
- 对每个召回的节点,在图里做深度为 1-2 的邻居扩展。先别做太深,否则图会迅速膨胀。
- 把展开后的节点沿着调用关系整理成一条或多条路径,例如
A.cancel -> B.refund -> C.deduct。 - 把路径上的节点摘要按顺序拼进 prompt。
一个常见的最小 prompt 结构如下:
请根据下面的代码引用路径回答用户问题。 引用路径: 1. OrderService.cancel (订单取消) - 调用 RefundService.refund 2. RefundService.refund (退款处理) - 调用 WalletClient.deduct 3. WalletClient.deduct (钱包扣款 RPC) - 注释:执行远程扣款 用户问题:取消订单之后,钱包余额会被扣掉吗?这种 prompt 把“路径”和“节点理解”分开,模型能看到上下文之间如何连接,而不是接收一堆无顺序的代码块。
3.3 一个最小伪代码示例
下面这个示例只用来展示流程结构,不一定匹配某个具体项目 API:
# 伪代码,仅演示流程 symbols = parse_ast(repo_path) graph = build_graph(symbols) for symbol in symbols: symbol.summary = summarize(symbol) # 模板或 LLM vector_db.insert(symbol.id, embed(symbol.summary)) for symbol in symbols: for callee in symbol.calls: graph.add_edge(symbol.id, callee.id, type="calls") def retrieve(question): seeds = vector_db.topk(question, k=5) subgraph = graph.expand(seeds, depth=2) paths = graph.extract_paths(subgraph, seeds) prompt = build_prompt(paths, question) return llm(prompt)这段代码不长,但已经组成了图谱 RAG 的最小骨架。后续优化方向也都集中在这几个函数里:摘要质量、扩展深度、路径抽取策略、prompt 编排。
3.4 如何验证中间结果,而不是只看最终答案
很多团队评估 RAG 只看“最终回答像不像”,这是很危险的。因为 LLM 生成能力很强,即使检索结果一般,也可能生成一个“听起来合理但实际不对”的答案。
你需要单独评估检索结果。我建议每跑一次查询,都把检索出的路径打印出来,人工对照:
- 入口节点是不是对的?
- 路径上有没有遗漏关键调用?
- 扩展出的节点是不是出现了大量无关工具类?
- 调用方向是不是正确?是“被谁调用”还是“调用了谁”?
只有路径对了,最终答案才有参考价值。如果路径不对,换个更强的 LLM 也没用。
4. 真正难的不是建图,而是长期好用
4.1 图构建质量,决定检索上限
一个代码图谱 RAG 系统的质量,80% 由“图构建阶段”决定。解析器选错、配置漏了、动态调用识别不了,都会导致边缺失或错误。常见的坑有这么几个:
- 解析器覆盖不全:项目里混用了多种语言,或者使用了 Lombok、注解处理器等需要编译期信息的语法,纯 AST 解析会丢内容。
- 动态调用识别不了:Spring 的
@Autowired、反射调用、SPI 扩展点,在静态解析下很难画出真实调用边。 - 构建产物没有包含全部文件:有的代码在
src/main/resources里配置了 SQL 映射,或者在pom.xml里生成了代码,不把这些绑定进来,图就不完整。
遇到问题建议按这个顺序排查:
- 先看节点总数是否异常偏少。如果节点数远小于代码里的函数数量,说明解析阶段丢东西了。
- 再看孤立节点比例。如果大量节点只有 0-1 条边,说明抽边逻辑有问题,或者解析器没有识别调用点。
- 抽一个核心业务方法,打印它的 callers 和 callees,和 IDE 里的 “Find Usages” 对比。如果 IDE 能看到的调用,图里没有,那就是解析配置缺了。
- 检查是否存在大量“悬空调用”,比如调用的目标符号在项目中不存在。这可能是因为跨仓库依赖没有建立,或者第三方库没有纳入解析范围。
不要用大模型去补缺失的边。模型可以生成摘要,但不能可靠地生成调用关系。调用关系应该来自静态解析,或者来自精确的动态追踪。
4.2 增量更新:代码库每天都在变,图不能每次都重建
如果只是做个 Demo,全量重建无所谓。但真实项目里,代码每天都有 commit,图必须能增量更新。
增量更新的最小单位是“文件”。当某个文件变化时,你需要:
- 重新解析这个文件,得到新的符号集合。
- 在图中删除这个文件对应的旧节点和旧边。
- 插入新的节点和边。
- 重新生成受影响节点的摘要和向量。
- 处理被影响的引用:如果其他文件还引用着被删除的符号,需要做一致性检查。
最常遇到的问题就是“悬空边”:B 函数被删了,A 函数里的调用关系还留在图里。如果不做定期清理,图会越来越脏,最终检索出来的路径可能经过一个不存在的函数。Graph 数据库里可以通过周期性的关系有效性检查来兜底,但设计阶段就应该把“变更事件”作为一个一等公民来处理。
对于第一个版本,更务实的做法是:每次 git commit 后,只重新构建变更文件的局部图,然后合并到全局图。不要做实时全量同步,先把每天一次的批处理跑起来,再考虑实时性。
4.3 上下文膨胀:图扩展不是越深越好
图谱 RAG 最常见的失控场景是:从一个函数出发,图扩展了 3 层,把所有邻居都塞进上下文,结果 prompt 里全是无关代码。模型被大量噪声包围,反而找不到重点。
我建议控制以下几个参数:
| 参数 | 建议起始值 | 说明 |
|---|---|---|
| 向量召回 TopK | 5 | 入口节点不宜过多 |
| 图扩展深度 | 1-2 | 深度 > 2 后,节点数通常爆炸 |
| 每层最大邻居数 | 20 | 只保留权重最高的边 |
| 节点摘要长度 | 100-200 token | 不要放完整函数体 |
| 最终 prompt 总长度 | 3000 token 以内 | 太长会影响模型注意力 |
实际操作中,我会把最相关的调用路径抽出来,而不是把整张子图给模型。比如先从入口到终点的最短路径,再补上路径上每个节点的“被谁调用”这一层。这样既保留了链路,又不会引入太多分支。
4.4 怎么评估这套方案值不值得继续投入
我建议不要用“准确率”作为唯一指标。产业链路的问答,答案往往是开放的,很难二值化。更实用的评估方式是用一组真实项目问题,跑三类指标:
- 入口命中率:向量召回的前 5 个节点里,是否包含正确答案中的核心函数?
- 路径覆盖率:图扩展得到的路径,是否覆盖了答案中必须出现的那些关键调用?
- 噪声率:最终 prompt 里有多少节点和问题无关?
你可以准备 30 个问题,人工标注每个问题的正确调用链。然后跑一遍系统,看看在不修改参数的情况下,三类指标的通过率。如果入口命中率低于 60%,问题大概率出在摘要和向量模型上;如果入口命中但路径覆盖率低,问题出在图构建或扩展深度上;如果路径覆盖但噪声率很高,问题出在路径抽取策略上。
这个评估体系可以复用,不管工具怎么换。
4.5 适用边界:什么人适合,什么人不适合
代码图谱 RAG 不是万能银弹。它适合的场景是:
- 大型遗留项目,新人入场需要快速理解业务链路。
- 跨模块重构,你想知道“改这个接口会影响哪些下游”。
- 代码评审辅助,针对一个 PR,快速生成影响面清单。
- 架构文档生成,从代码图里抽取模块依赖、服务调用链。
- 问答式开发辅助,当问题涉及多个文件时,提供比普通 RAG 更连贯的上下文。
它不适合的场景是:
- 只想知道某个函数怎么写的,用 IDE 或者普通代码搜索更快,没必要建图。
- 对实时性要求极高的场景,比如在 CI 的每次提交里都跑全量图构建,代价过高。
- 需要严格保证安全属性的场景,图 RAG 不能替代静态分析工具去验证空指针、死锁、权限漏洞等。
如果只是一个人维护一个小项目,我甚至不建议上代码图谱 RAG。先把手写的文档和普通 RAG 用好,比堆技术更高效。当你的问题开始频繁发生在“两个文件之间”“两个服务之间”,图的价值才会真正显现。
4.6 给团队落地的一条建议路径
不要一开始追求完美图。先按“最小闭环”来走:
- 选一个小型业务模块,做代码解析和手工校验调用关系。
- 用模板摘要跑通向量召回 + 图遍历的最小流程。
- 准备 20-30 个真实问答对,人工标注正确链路。
- 观察检索路径质量,针对性优化图构建。
- 稳定后再扩展到整个仓库,并接入 git 变更做增量更新。
这套路径里,第一步和第三步往往最容易被忽略。很多人急着把图建得很大,结果没有评估集,后面根本不知道优化到哪个方向。
说到底,code-graph-rag只在名字里给了你答案的一半:用图来组织代码。另一半要靠你结合自己的项目去实现——构建什么图、如何保证图的正确性、怎么从图里抽取上下文。这里面没有银弹,但有一条可以走通的路。先让模型按正确的路径看到代码,再谈让它理解代码。