转载

community|15 分钟阅读|2024 年 11 月 6 日
本文为社区作者 Sankalp Shubham 投稿

引言

如果你用过 Cursor 编辑器的 @codebase 功能,你大概知道它在理解代码库时有多有用:不管是找相关文件、追踪执行流,还是理解类与方法的用法, @codebase 都能收集必要上下文来帮你。

我做了一个叫 CodeQA 的项目,能做上述事情。和 Cursor 的 @codebase 类似,CodeQA 能在整个代码库范围内回答你的问题,并给出相关片段、文件名与引用。它支持 Java、Python、Rust、JavaScript,也很容易拓展到其它语言。

  • 实现见:CodeQA GitHub 链接
  • 效果预览:CodeQA Demo 链接

这个事情有两个关键部分:代码库索引(上篇)检索管线(下篇) 。本文全部讨论:如何借助 tree-sitter 做代码库索引,以及用 LanceDB 做嵌入检索。

image.png

image.png

你会看到什么

本文更侧重概念与可选路径,而非大段具体代码(末尾有少量代码)。

重点包含:为什么“把上下文喂进模型”不是最佳解、朴素的语义代码搜索、分块类型,以及使用 tree-sitter语法级切分

预备知识:Python 编程、词向量/嵌入
术语说明query 指提问;LLM 指大语言模型;natural language 指自然语言(如英文)

问题定义

目标是做一个应用,帮助用户理解其代码库并生成更准确的代码,能力包括:

  • 当用户输入关键字 @codebase 时,能以准确且相关的信息回答关于整个代码库的自然语言问题。

  • 提供上下文代码片段并分析代码的使用模式,包括:

    • 某个类/方法是如何被使用的
    • 类构造函数与实现的细节
  • 跨代码库追踪并利用引用关系

    • 找出代码元素在全库内的使用处
    • 为所有回答提供来源引用
  • 利用收集到的上下文,辅助代码生成与建议

问题既可能是单跳(单一来源即可回答),也可能是多跳(需聚合多个来源后作答)。

例子:

  • “这个 git 仓库是做什么的?”
  • “某个方法是怎么工作的?” → 你可能没写出精确的方法名
  • “哪些方法在做 xyz 这样的事?” → 系统应能从你的提问中识别相关的方法/类名
  • 更复杂的如:“EncoderClassDecoderClass 之间有什么联系?” → 系统应跨多处收集上下文后回答(多跳问答)

既然问题清楚了,我们来想解法。我们允许使用 LLM

为什么 GPT-4 不能直接回答我代码库里的问题?

虽然 GPT-4 训练过海量代码,但它并不了解你的代码库:不了解你定义的类、使用的方法,或项目整体目的。

它能回答通用编程问题(比如 “useEffect 怎么工作”),但对你的自定义代码问题(比如 “generateEmbeddings() 可能如何工作”)就不行,因为它从没见过你这份实现/需求。

如果你问它你的代码,GPT-4 要么承认不知道,要么幻觉——编出“看似合理但错误”的答案。

“把上下文放进提示里”(In-Context Learning)

现代 LLM(如 Anthropic Claude、Google Gemini)有很大上下文窗口——从 Claude Sonnet 的 200K token 到 Gemini Pro 1.5 的 2M token。它们很擅长从上下文中学习(ICL),在提示里给出示例即可让模型少样本学会模式与任务(来源)。

这意味着:把小到中型代码库(你的 side-project 全部代码)塞进 Sonnet 3.5 里,甚至把大型代码库塞进最新的 Gemini Pro 1.5,也能“用你自己的代码上下文”去问问题。我也经常用上面两款来理解代码库,尤其是 Gemini(效果出乎意料地好)。
一个小技巧:把 GitHub 仓库 URL 里的 github 改成 uithub(把 g 换成 u),可以更容易地整体复制贴进提示。
本地工具我有时用 code2prompt

  • Few-shot 学习:在 system prompt 或提示中放入信息/示例,用作参考或模式学习。
  • In-context 学习:在推理时把信息喂给 LLM,它能利用这些信息作答或学习新模式。最近的论文(In-Context Learning with Long-Context Models: An In-Depth Exploration)显示大上下文模型在给出成千上万示例时性能会提升。(有线程链接)

为什么 ICL 不是最佳方案

  • 性能退化:当上下文窗口被塞满时,代码生成/理解能力开始下降。尽量少而准的上下文更好。
  • 成本低效:走 API 时,每次会话粘一大堆 token,很快就贵。
  • 时间开销:把大量代码粘进 Gemini 可以,但初始化处理提示很耗时,窗口变大后响应也会变慢。自用还行。
  • 用户体验:你不能让终端用户自己把整个代码库复制进 LLM()。
  • 相关性问题:塞太多无关代码会伤害回答质量。模型可能被无关段落干扰,而不是聚焦真正需要的部分。

让上下文更相关

显然,我们要最小化无关内容进入提示。RAG 的目的正是如此。

回忆一下,我们的 query 是自然语言,且可能不包含精准的类/方法名,所以关键词/模糊检索不灵。要把英文问题映射到代码符号(类名、方法名、代码块),我们可以利用向量嵌入的语义搜索

下面讨论如何抽取并索引代码块以生成高质量嵌入——也就是构建一个朴素的语义代码搜索系统

嵌入 101 与“结构”的重要性(可跳过)

你可以看 Embeddings: What they are and why they matter 作为入门。

用例子理解“嵌入与分块”

先不用代码,看看为什么需要“分块”,以及嵌入怎么工作。
比如我们想从 Paul Graham 的博客里找“关于野心(ambition)的引用”。

为什么要分块? 因为嵌入模型有序列长度上限(通常 1024–8192 tokens)。长文必须拆成更小且有意义的段落,便于更准确地匹配且不破坏语义。

常见做法:

  • 把文本拆成模型可处理的小块,
  • 读起来仍然通顺
  • 保留足够上下文

分块方式:

  • 定长分块(等长切)
  • 智能分块(如 RecursiveCharacterTextSplitter

可用工具:langchain、llama-index——它们带有针对不同内容类型的分块器:

  • 通用文本:RecursiveCharacterTextSplitter
  • Markdown 专用分块器
  • 语义分块(Semantic chunking)

关键点:不同内容需要不同分块策略。博客、代码、技术文档都有自己的结构——我们需要保留结构,才能让嵌入模型表现好。

从头到尾流程:

  1. 按内容类型把原文分块

  2. 用模型(如 sentence-transformers/bge-en-v1.5)把块转成嵌入

  3. 把嵌入到数据库或 CSV

  4. 同时保存原文与必要元数据

  5. 检索时:

    • 把用户搜索词转成嵌入
    • 相似度(点积/余弦)
    • 返回匹配块

理解“分块与嵌入”很关键,下面我们会谈到代码专用分块策略——这时维护代码结构就更重要。

走向一个“朴素语义搜索”的解决方案

流程如下:

  1. 为整个代码库生成嵌入
  2. 用户输入查询
  3. 把查询转嵌入,计算余弦相似
  4. 语义代码检索完成
  5. Top-5 匹配代码块
  6. 实际代码(带元信息)作为上下文喂给 LLM
  7. LLM 生成答案

我们需要搞清楚:如何对代码库做嵌入以获得更好的语义搜索质量。

检索(向量或其它,如 SQL)得到的上下文喂给 LLM 以辅助生成(并避免幻觉)就是 RAG。建议阅读 Hrishi 的三部曲(从基础到进阶),本文是对其中 Part 1 与 Part 3 的应用

给代码库“分块”

像处理普通文本那样“按定长/按段落”分块不适合代码。代码有特定语法与明确结构(类、方法、函数)。为了有效处理与嵌入代码,必须保持其语义完整性

直觉:在潜在空间里,具有相似结构的代码会彼此更相似。再者,嵌入模型往往训练过代码片段,更能捕捉代码间的关系。

检索时,返回完整的方法块或至少能引用完整块,会更有帮助,而不是零碎片段。

我们也希望提供引用信息(references),无论在生成嵌入时用,还是在把上下文送进 LLM时用。

方法/类级分块

可以做方法级类级的朴素切分。参考:OpenAI cookbook(提取函数并做嵌入)。
但这不语言无关,而且一旦超出“类/方法”,实现会变得很重。

语法级分块

代码的分块挑战在于:

  • 如何处理层级结构
  • 如何抽取语言特定结构(构造函数、class)?
  • 如何语言无关?不同语言语法各异。

这就是语法级分块的意义。

  • 我们把代码解析成 AST(抽象语法树)
  • 沿 AST 遍历,抽取子树节点作为块(如函数声明、类定义、整段类代码、构造函数调用等),粒度可细到单个变量
  • 也可以(通过一定实现)拿到全库范围的引用关系
  • 借助 AST,可以捕捉代码的层级结构与关系

如何构建 AST?

Python 自带的 ast 库只适用于 Python。我们需要语言无关的方案。

我开始更深入地搜:看技术博客、逛 GitHub,与做开发者工具的朋友讨论。一个模式渐渐冒出来:tree-sitter 频繁出现。

更有意思的是它在开发者工具生态里的广泛应用:

  • YC 背景的公司在用:

    • **Buildt(YC’23)**在技术讨论里提过它(有截图)
  • 现代编辑器基于它:

    • Cursor.sh 用它做代码库索引
    • 他们构建代码图的方法高度依赖 tree-sitter
  • 开发者工具逐渐标准化

    • Aider.chat(AI 终端结对编程)使用 tree-sitter 做 AST 处理
    • 他们关于用 tree-sitter 构建仓库地图的文章写得很好

什么是 tree-sitter?

tree-sitter 是一个语法分析器生成器增量解析库。它能够为源码构建具体语法树(CST) ,并在源码编辑时高效增量更新语法树。目标:

  • 通用:解析任意编程语言
  • 够快:几乎每次敲键都能解析
  • 健壮:即使有语法错误也能给出有用结果
  • 零依赖:纯 C 运行库

它被用于 Atom、VSCode 等编辑器,实现语法高亮代码折叠等。显然,neovim 社区对 tree-sitter 也很狂热。

关键特性是“增量解析” → 当代码变化时,能高效更新语法树,非常适合做编辑器特性(如高亮、自动缩进)。

沿着“编辑器内部实现”继续挖(朋友建议),我发现它们通常结合 AST 库 + LSP(语言服务器协议) 。虽然 LSIF(LSP 的知识格式)也可以用于代码嵌入,但因为多语言支持复杂,我暂时跳过了。

  • Tree-sitter explainer:视频(略)

用 tree-sitter 深入语法

最快上手方式是 tree_sitter_languages 模块,内置了多语言的预编译解析器:

pip install tree-sitter-languages

你也可以在 tree-sitter playground 上玩。

从 AST 抽取“方法与类”(或任意代码符号)

对应代码在 tutorial/sample_one_traversal.py

看一段示例代码的 AST:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def calculate_area(self):
        """Calculate the area of the rectangle."""
        return self.width * self.height
        
my_rectangle = Rectangle(5, 3)
area = my_rectangle.calculate_area()

tree-sitter 的 AST(简化版)

module [0, 0] - [12, 0]
  class_definition [0, 0] - [7, 39]  # Rectangle
    name: identifier [0, 6] - [0, 15]
    body: block [1, 4] - [7, 39]
      function_definition [1, 4] - [3, 28]  # __init__
        name: identifier [1, 8] - [1, 16]
        parameters: parameters [1, 16] - [1, 37]
          // ...
        body: block [2, 8] - [3, 28]
          // ...
      function_definition [5, 4] - [7, 39]  # calculate_area
        name: identifier [5, 8] - [5, 22]
        parameters: parameters [5, 22] - [5, 28]
          // ...
        body: block [6, 8] - [7, 39]
          // ...
  expression_statement [9, 0] - [9, 30]  # my_rectangle = Rectangle(5, 3)
    ...
  expression_statement [10, 0] - [10, 36]  # area = my_rectangle.calculate_area()
    ...

递归遍历 AST 抽取“类与方法” (节选):

from tree_sitter_languages import get_parser

parser = get_parser("python")
code = """ ... """
tree = parser.parse(bytes(code, "utf8"))

def extract_classes_and_methods(node):
    results = {'classes': [], 'methods': []}
    def traverse_tree(node):
        if node.type == "class_definition":
            class_name = node.child_by_field_name("name").text.decode('utf8')
            class_code = node.text.decode('utf8')
            results['classes'].append({'name': class_name, 'code': class_code})
        elif node.type == "function_definition":
            method_name = node.child_by_field_name("name").text.decode('utf8')
            method_code = node.text.decode('utf8')
            results['methods'].append({'name': method_name, 'code': method_code})
        for child in node.children:
            traverse_tree(child)
    traverse_tree(node)
    return results

使用 tree-sitter Query

tutorial/sample_two_queries.py。定义查询并用来抽取类/方法(节选):

class_query = language.query("""
    (class_definition
        name: (identifier) @class.name
    ) @class.def
""")

method_query = language.query("""
    (function_definition
        name: (identifier) @method.name
    ) @method.def
""")

全库范围的“引用关系”

全库引用,我主要找函数调用类实例化/对象创建。下面是 preprocessing.py 的相关代码(节选):

def find_references(file_list, class_names, method_names):
    references = {'class': defaultdict(list), 'method': defaultdict(list)}
    ...
    for language, files in files_by_language.items():
        treesitter_parser = Treesitter.create_treesitter(language)
        for file_path in files:
            ...
            tree = treesitter_parser.parser.parse(file_bytes)
            stack = [(tree.root_node, None)]
            while stack:
                node, parent = stack.pop()
                if node.type == 'identifier':
                    name = node.text.decode()
                    if name in class_names and parent and parent.type in ['type','class_type','object_creation_expression']:
                        references['class'][name].append({...})
                    if name in method_names and parent and parent.type in ['call_expression','method_invocation']:
                        references['method'][name].append({...})
                stack.extend((child, node) for child in node.children)
    return references

这是一个基于栈的树遍历来找引用。我用了比 query/tag 更简单的办法:

  • 如果标识符名字命中类名集合,且父节点属于类型注解/对象创建等,就记录为类引用
  • 方法同理(调用表达式 / 方法调用)。

这些引用作为每个代码块的元数据存储。当向量库返回最终文档时取出,用作最终输出的增强信息

结语

本篇讨论了一个朴素语义代码搜索方案,以及如何用 tree-sitter语法级分块。到这里我们几乎完成了预处理下篇会讲:如何生成嵌入、提升嵌入检索的技巧,以及一些后处理方法。


在上篇,我讲了面向代码库的问答系统:为什么 GPT-4 无法“天然”回答代码问题、ICL 的局限、用嵌入做语义代码搜索的重要性,以及用 tree-sitter语法级分块,抽取方法、类、构造函数跨库引用

本篇涵盖

  • 代码库索引的最后一步——给方法加 LLM 注释
  • 选择嵌入模型与向量库的注意点
  • 提升检索的技巧:HyDE、BM25、重排序
  • 重排序的选择与深入解释(双塔 vs 交叉编码器

如果你用过嵌入就会知道:仅靠嵌入搜索往往不够,需要在管线里加 HyDE / 混合检索 / 重排序 等步骤。

接下来逐一讲述,并给出实现要点。

给方法添加 LLM 注释(可选)

严格说这也属于“索引”的一部分,但更贴近本篇主题。

由于用户的提问是自然语言,我决定为每个方法添加2–3 行的自然语言文档。这样代码库就被“注释化”了:每条 LLM 生成的注释给出简要概述。它能提升关键词语义搜索的效果——你既能按“代码做什么”,也能按“代码是什么”来搜。

后来我找到了篇博客验证了这个想法(Cosine:三招让嵌入检索准确率提升 37% ),其中谈到:

嵌入与向量数据库(VectorDB)

我默认用 OpenAI text-embedding-3-large(表现好,大家也都有 OpenAI key)。也提供 jina-embeddings-v3 选项,在一些基准上优于 text-embedding-3-large

关于 OpenAI 嵌入:便宜、8191 token 序列长度、MTEB 第 35 名、多语言、API 好用。序列长度很重要,因为能覆盖更长的依赖与上下文。

选择嵌入时要考虑

  • 基准:看 MTEB 排名、分数、序列长度、支持语言。开源模型需考虑参数量/显存
  • 延迟:API-based 会有网络往返;要速度且有算力可选本地
  • 成本:尝试/免费可用 sentence-transformers(如 bge-en-v1.5、nomic-embed-v1);闭源大多也不贵。
  • 用例匹配:嵌入是否含代码预训练
  • 微调:可显著提升效果。LlamaIndex 提供对 HF 嵌入的微调 API;Jina 也出了微调 API(我还没试)。
  • 隐私:对很多人和公司很关键。我的项目以性能优先,选择了 OpenAI,未优先考虑隐私。

这里引用 Sourcegraph 的一段博文,说明他们为什么不再使用嵌入(为多仓场景带来的数据传输、维护与规模复杂度等成本)。可参考 《Cody 如何理解你的代码库》 一文。

向量库(VDB)

我用 LanceDB快、易用pip install 即用,无需 API key。对 Hugging Face 上几乎所有嵌入/主流服务(OpenAI、Jina 等)都有集成;也支持重排序器、算法、嵌入、第三方 RAG 库等。

选择 VDB 时考虑:

  • 是否支持你需要的集成(LLMs、不同厂商)
  • 召回与延迟
  • 成本
  • 易用性
  • 开源/闭源

实现细节:嵌入代码库与建表的代码在 create_tables.py。我维护两个表:一个放方法,一个放类与其它(如 README)。分开有利于分别查询元数据分别做向量检索(比如先找最近的类,再找最近的方法)。

在实现里,你会看到我没有手动生成嵌入。这部分由 LanceDB 托管:我只需要写入分块,LanceDB 在后台做批量嵌入生成重试

检索

表准备好后,就能把查询交给向量库。它会用暴力搜索(余弦/点积)返回最相似文档。这些结果相关不总是够准;顺序也可能不理想。附了个推特线程展示这些短板。

提升嵌入检索

BM25 是第一步:把语义检索关键词检索结合。我在 semantweet search 里这么做过。很多向量库都内置了混合检索。那次我用 LanceDB,配合 tantivy 建全文检索索引即可。LanceDB 官网也有一些基准来展示不同方法对结果的影响。

召回(Recall) :检索到的相关文档数 / 实际相关文档总数 —— 衡量搜索性能。

用元数据做过滤:如果数据带有日期/关键词/指标,可先用 SQL 预过滤再做语义搜索,能显著提升质量与性能。因为嵌入是“空间中的点”,先缩窄候选空间很有帮助。

重排序(Reranking)

在向量检索后加重排序非常容易且收益显著。简言之,重排序器对“查询 token候选文档 token”做交互(cross-attention)

CodeQA v2 里我用 answerdotai/answerai-colbert-small-v1(本地最强之一,接近 Cohere Re-ranker v3,我在 v1 用过它)。

交叉编码器(cross-encoder) vs. 双塔(bi-encoder)

嵌入来自像 GPT-3(decoder-only)BERT(encoder-only) 这类模型。

两种工作方式

  • 交叉编码器:把 query+doc 连接后一起编码,输出相关性分数
  • 双塔编码器分别编码 query 与 doc,然后点积相似度。

交叉编码器里,所有 query token 与 doc token 两两交互,因此更准;但需要对每个候选都跑一次模型,很慢。双塔可以预计算文档向量,在线只算点积,很快略逊精度
所以实践上常用“先双塔快召回,再交叉重排 Top-K”。

文中给了一个示例(略),展示了双塔把“How many people live in New Delhi? No idea. ”错判为最相关,而交叉重排能把真正答案(含具体人口数的句子)排到第一。

HyDE(假设文档嵌入)

用户的 query 多是英文自然语言,而我们的嵌入主要由代码组成。在潜在空间里,“代码更接近代码”,而不是“英文接近代码”。这就是 HyDE 的直觉:

  • 先让 LLM 为你的问题生成一个“假想答案/文档” (可能是代码片段)。
  • 用这个**“幻觉出来的文档”去做首次向量检索**,因为它在嵌入空间里更贴近代码
  • 取回Top-5 结果组成临时上下文,再发起第二次 HyDE(此时模型已“知道语言/文件”)。
  • 第二次 HyDE 生成一个更具上下文意识的查询。
  • 用它做向量检索重排序,然后取回代码与引用元数据拼上下文给聊天 LLM。

实现上我用 gpt-4o-mini 做两次 HyDE(便宜、快、对代码理解也还行),系统提示模板在文中已给出(略)。最终聊天用 gpt-4o(要更强的对话体验;有 OpenAI key 更方便,或者用 Claude 3.5 Sonnet)。

另外,如果用户没带 @codebase,系统会回退到已有上下文;若模型不自信,会提示用户加上 @codebase(这在 system prompt 里约定)。

可能的改进

延迟:还有很多可以优化的地方。目前带 @codebase不重排10–20s,开重排是 20–30s(有点慢)。为自我辩护一下:Cursor 也常在 15–20s 左右。

  • 最容易的优化:用 Llama 3.1-70B,并跑在 Groq/Cerebras 这类高吞吐推理服务,能把 HyDE 的延迟降 ≥10s
  • 尝试本地嵌入以消除网络延迟。
  • 我们的瓶颈在 HyDE。能否把两次 HyDE 减到一次,甚至去掉?也许借助仓库地图上的 BM25 先做关键词预选,能减少 1 次 HyDE。

准确率

  • 低垂果实:换/微调更好的嵌入
  • 评测反馈闭环来迭代优化。

结语

在这两篇文章里,我们探讨了一个实用的代码问答系统应如何构建。上篇奠定了基础:正确的代码分块语义代码搜索下篇深入了提升检索质量的技巧:

  • LLM 注释把“代码”与“自然语言查询”桥接
  • 选择嵌入与向量库的考量;
  • 混合检索(语义 + 关键词)交叉重排的力量,以及
  • HyDE 缩小“自然语言 ↔ 代码”之间的语义鸿沟

完整实现都在 GitHub,欢迎在自己的项目里试试这些方法。谢谢阅读!

参考(按出现顺序;与原文一致,略)


本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]