第2372篇:代码知识库RAG——让AI能搜索和理解你的代码仓库
大约 6 分钟
第2372篇:代码知识库RAG——让AI能搜索和理解你的代码仓库
适读人群:想构建代码搜索和理解工具的AI工程师 | 阅读时长:约20分钟 | 核心价值:掌握代码知识库的索引策略、语义搜索和代码理解的完整工程方案
我们组去年在内部推了一个"代码助手"项目,原始需求是:新人入职后,不用翻文档,直接问AI就能知道"这个功能在哪里实现的"、"这个接口的业务逻辑是什么"。
一开始做法很粗糙:把所有Java文件直接按行切片,塞进向量库。效果惨不忍睹。
问"用户登录是怎么实现的",检索到的是一堆零散的import语句和注释片段。问"怎么调用支付接口",检索到的是支付相关的枚举类型定义,不是实际的调用代码。
根本原因在于:代码不是自然语言文本,不能按自然语言的方式来切片和检索。代码有结构——类、方法、调用关系——这些结构是理解代码的核心。
代码索引的核心问题
代码RAG和文档RAG的本质差异:
/**
* 代码索引面临的核心挑战
*
* 挑战1:切片粒度问题
* - 按行切:太碎,一个方法被切成多段,语义不完整
* - 按文件切:太大,一个文件几百行,超出上下文
* - 正确方式:按代码结构切(方法级别最佳)
*
* 挑战2:语义表示问题
* - 代码的"语义"和自然语言不同
* - "用户登录"可能在代码里叫 authenticate、login、signIn
* - 向量检索需要理解自然语言查询和代码之间的语义鸿沟
*
* 挑战3:上下文依赖问题
* - 一个方法引用了父类的变量
* - 单独看这个方法,不知道变量是什么
* - 需要把依赖上下文一起带进来
*
* 挑战4:代码变化频繁
* - 代码仓库每天都有提交
* - 需要增量更新,不能每次全量重新索引
*/代码解析:基于AST的结构化切片
@Service
public class JavaCodeParser {
/**
* 解析Java代码,提取方法级别的代码块
* 使用JavaParser库做AST解析
*/
public List<CodeChunk> parseJavaFile(String filePath, String sourceCode) {
CompilationUnit cu;
try {
cu = StaticJavaParser.parse(sourceCode);
} catch (ParseProblemException e) {
log.warn("Failed to parse Java file: {}", filePath);
return fallbackParsing(filePath, sourceCode);
}
List<CodeChunk> chunks = new ArrayList<>();
String packageName = cu.getPackageDeclaration()
.map(pd -> pd.getNameAsString())
.orElse("");
// 遍历所有类
cu.findAll(ClassOrInterfaceDeclaration.class).forEach(classDecl -> {
String className = classDecl.getNameAsString();
String classDoc = extractJavadoc(classDecl);
// 提取类级别的元数据(不含方法体)
CodeChunk classChunk = CodeChunk.builder()
.id(filePath + "#" + className)
.type(ChunkType.CLASS)
.filePath(filePath)
.packageName(packageName)
.className(className)
.content(extractClassSignature(classDecl))
.documentation(classDoc)
.annotations(extractAnnotations(classDecl))
.build();
chunks.add(classChunk);
// 遍历所有方法
classDecl.getMethods().forEach(method -> {
CodeChunk methodChunk = buildMethodChunk(
filePath, packageName, className, method
);
chunks.add(methodChunk);
});
});
return chunks;
}
private CodeChunk buildMethodChunk(String filePath, String packageName,
String className, MethodDeclaration method) {
String methodSignature = buildMethodSignature(method);
String methodBody = method.toString();
String javadoc = extractJavadoc(method);
// 构建自然语言描述(用于提升向量检索效果)
String naturalDescription = buildNaturalDescription(
className, method, javadoc
);
return CodeChunk.builder()
.id(filePath + "#" + className + "#" + method.getNameAsString())
.type(ChunkType.METHOD)
.filePath(filePath)
.packageName(packageName)
.className(className)
.methodName(method.getNameAsString())
.signature(methodSignature)
.content(methodBody)
.documentation(javadoc)
.naturalDescription(naturalDescription)
.annotations(extractAnnotations(method))
.calledMethods(extractCalledMethods(method))
.build();
}
/**
* 构建自然语言描述
* 关键:这个描述会和用户的自然语言查询做向量相似度计算
*
* 所以要把代码的"意图"翻译成自然语言
*/
private String buildNaturalDescription(String className,
MethodDeclaration method,
String javadoc) {
StringBuilder desc = new StringBuilder();
desc.append("类 ").append(className).append(" 的方法 ");
desc.append(method.getNameAsString()).append("。\n");
if (!javadoc.isEmpty()) {
desc.append("功能说明:").append(javadoc).append("\n");
}
// 参数信息
if (!method.getParameters().isEmpty()) {
desc.append("参数:");
method.getParameters().forEach(p ->
desc.append(p.getNameAsString()).append("(").append(p.getTypeAsString()).append(")")
);
desc.append("\n");
}
// 返回值
desc.append("返回:").append(method.getTypeAsString()).append("\n");
// 注解(Spring注解很有语义价值)
List<String> annotations = extractAnnotations(method);
if (!annotations.isEmpty()) {
desc.append("注解:").append(String.join(", ", annotations)).append("\n");
}
return desc.toString();
}
}向量化策略:代码 + 自然语言双轨
@Service
public class CodeEmbeddingService {
private final EmbeddingModel textEmbeddingModel; // 通用文本向量模型
// 注:code-specific模型效果更好,如CodeBERT、GraphCodeBERT
// 但这里用通用模型+自然语言增强也能达到不错效果
/**
* 代码块的向量化
*
* 核心策略:不直接对代码向量化,而是对
* "自然语言描述 + 代码" 的组合向量化
*/
public float[] embedCodeChunk(CodeChunk chunk) {
// 构建混合文本:自然语言描述在前,代码在后
String embeddingText = buildEmbeddingText(chunk);
return textEmbeddingModel.embed(embeddingText);
}
private String buildEmbeddingText(CodeChunk chunk) {
StringBuilder text = new StringBuilder();
// 1. 自然语言描述(权重高)
if (chunk.getNaturalDescription() != null) {
text.append(chunk.getNaturalDescription()).append("\n\n");
}
// 2. Javadoc(如果有)
if (chunk.getDocumentation() != null && !chunk.getDocumentation().isEmpty()) {
text.append("文档:").append(chunk.getDocumentation()).append("\n\n");
}
// 3. 方法签名(很重要,包含方法名、参数类型)
if (chunk.getSignature() != null) {
text.append("签名:").append(chunk.getSignature()).append("\n\n");
}
// 4. 代码内容(截断,避免太长)
String code = chunk.getContent();
if (code.length() > 1000) {
code = code.substring(0, 1000) + "... (truncated)";
}
text.append("代码:\n").append(code);
return text.toString();
}
}检索增强:代码特有的上下文补全
@Service
public class CodeContextEnricher {
/**
* 检索到代码块后,补全必要的上下文
*
* 场景:
* - 检索到一个方法,但方法里用到了类的字段
* - 需要把类的字段定义也带进来
*/
public EnrichedCodeContext enrich(CodeChunk chunk) {
EnrichedCodeContext.Builder builder = EnrichedCodeContext.builder()
.primaryChunk(chunk);
// 1. 补全类级别的上下文(字段、父类信息)
CodeChunk classContext = findClassContext(chunk.getFilePath(), chunk.getClassName());
if (classContext != null) {
builder.classContext(classContext);
}
// 2. 补全被调用方法的签名(不要完整方法体,太长了)
List<String> calledSignatures = new ArrayList<>();
for (String calledMethod : chunk.getCalledMethods()) {
String signature = findMethodSignature(calledMethod);
if (signature != null) {
calledSignatures.add(signature);
}
}
builder.calledMethodSignatures(calledSignatures);
// 3. 如果是接口方法,找实现类
if (isInterfaceMethod(chunk)) {
List<CodeChunk> implementations = findImplementations(chunk);
builder.implementations(implementations);
}
return builder.build();
}
/**
* 把富化后的上下文格式化为LLM可读的格式
*/
public String formatForLLM(EnrichedCodeContext context) {
StringBuilder sb = new StringBuilder();
// 主要代码
sb.append("## 主要代码\n");
sb.append("文件:").append(context.getPrimaryChunk().getFilePath()).append("\n");
sb.append("```java\n");
sb.append(context.getPrimaryChunk().getContent());
sb.append("\n```\n\n");
// 类上下文
if (context.getClassContext() != null) {
sb.append("## 所在类的字段和构造\n");
sb.append("```java\n");
sb.append(context.getClassContext().getContent());
sb.append("\n```\n\n");
}
// 调用的方法签名
if (!context.getCalledMethodSignatures().isEmpty()) {
sb.append("## 调用的方法签名\n");
for (String sig : context.getCalledMethodSignatures()) {
sb.append("- ").append(sig).append("\n");
}
sb.append("\n");
}
return sb.toString();
}
}增量更新:跟踪代码变更
@Service
public class CodeRepositoryWatcher {
/**
* 监听Git仓库变更,增量更新索引
*
* 思路:记录每个文件的最后提交hash,
* 有新提交时,只重新索引变更的文件
*/
@Scheduled(fixedDelay = 60000) // 每分钟检查一次
public void checkForChanges() {
try {
// 获取自上次检查以来的变更文件
List<ChangedFile> changedFiles = gitClient.getChangedFilesSince(lastCheckedCommit);
for (ChangedFile file : changedFiles) {
if (file.getPath().endsWith(".java")) {
switch (file.getChangeType()) {
case ADDED, MODIFIED -> reindexFile(file.getPath());
case DELETED -> removeFromIndex(file.getPath());
case RENAMED -> {
removeFromIndex(file.getOldPath());
reindexFile(file.getPath());
}
}
}
}
lastCheckedCommit = gitClient.getLatestCommitHash();
} catch (Exception e) {
log.error("Failed to check for code changes", e);
}
}
private void reindexFile(String filePath) {
try {
String sourceCode = gitClient.readFile(filePath);
// 删除旧的索引
vectorStore.delete(findChunksByFile(filePath));
// 重新解析和索引
List<CodeChunk> chunks = codeParser.parseJavaFile(filePath, sourceCode);
List<Document> documents = chunks.stream()
.map(this::chunkToDocument)
.collect(Collectors.toList());
vectorStore.add(documents);
log.info("Reindexed {} chunks from file: {}", chunks.size(), filePath);
} catch (Exception e) {
log.error("Failed to reindex file: {}", filePath, e);
}
}
}实际效果
做完这套之后,对比了一下:
- 方法级切片 + 自然语言描述增强,检索准确率比文件级切片提升了约60%
- 代码上下文补全之后,LLM生成解释的准确率明显提升(不再因为缺少类字段定义而产生误解)
- 增量更新让索引延迟从"重新全量构建需要2小时"变成"代码提交后1分钟内索引更新"
一个小经验:对于有良好注释的代码库,这套方案效果很好。对于几乎没有注释的代码库,效果会差很多——这时候可以用LLM先自动生成方法的自然语言描述,再入索引,但这会带来额外的生成成本。
