[控场AI]
· 12 分钟阅读· 6,493 字

用tree-sitter按函数切块,别用512令牌切代码

用tree-sitter按函数切块,别用512令牌切代码

用 tree-sitter 按函数和类边界切块,替代固定 512 token 窗口,让代码检索不再丢符号名。

文章指出固定 token 窗口切块器会无视代码结构,在函数中间切断,导致检索结果要么是没有函数体的签名、要么是没有名字的逻辑。作者主张用 tree-sitter 解析 Python,在 def/class 边界切块,把文件路径和所属符号写入嵌入文本的元数据头部;对超大的类和函数先按语句边界打包,每条片段都保留完整签名,只有单条超大语句才回退到行窗口。作者实现了一个固定窗口切块器和一个 AST 切块器共用 480 token 预算,并用带标注的查询集评测。评测刻意使用 touched@5 和 whole@5 两个指标:宽松指标对窗口切块有利,而 whole@5 的差距量化了“函数体丢了名字”这一问题的严重程度。生产化还需要处理多语言节点名、解析错误回退、更换代码训练嵌入模型,并在向量检索旁融合关键词索引。

代码库助手的检索结果常常从函数的 except 子句开始,既没有 def 行也没有类名——只有文件路径能说明它的来路。模型却会自信地写出一个它从未见过名字的函数解释。问题的根源不在模型,而在切块器:它按处理散文的方式,每 512 个 token 切一刀,切口正好落在函数中间。

代码在 def 和 class 处有天然接缝,而 token 计数器对此一无所知。本文的做法是:用 tree-sitter 解析 Python,在函数和类边界切块,把文件路径和所属符号写入元数据,对超大的定义再按语句边界切分。最后用一组带标注的查询集给两种切块器打分,让你在自己的机器上看到差异。

rss source: Code Search RAG: Chunk by Function With tree-sitter, Not by 512 Tokens

固定窗口对函数做了什么

固定窗口完全不知道函数从哪里开始。如果一个函数从 512-token 窗口的第 470 个 token 开始,这个块只能拿到装饰器、签名和四行函数体;下一个块拿到剩下的函数体,却没有任何名字。当你问“解析配置文件的函数在哪”时,检索器匹配到的是一个没有逻辑的签名,或者一段没有签名的逻辑。

卡内基梅隆大学和 Augment Code 的 cAST 论文发现同样的问题:“现有的基于行的切块启发式方法经常破坏语义结构,把函数切开或把不相关的代码合并在一起。”他们基于 AST 的切块器会拆分过大的语法节点、在尺寸限制下合并小兄弟节点,在 RepoEval 检索上取得 4.3 个百分点的 Recall@5 提升,在 SWE-bench 生成上取得 2.67 个百分点的 Pass@1 提升。

框架也在往同一个方向走。LlamaIndex 提供了一个 CodeSplitter,用 tree-sitter 解析,源码中默认把块限制在 1500 个字符。cAST 作者发布了 astchunk,按非空白字符衡量块大小,支持 Python、Java、C# 和 TypeScript。这些工具都可以直接用,但自己写一遍才知道它们在为你做什么,以及当代码库里有 600 行的函数时该怎么调。

Token windows cut wherever the count runs out. AST chunks cut where the code does.

tree-sitter 是一个增量式解析器生成器与运行时库,能为源码生成具体语法树(CST),而非抽象语法树(AST)。它支持数十种语言、容错解析,并且解析速度极快。关键点在于:tree-sitter 把每种语言的语法规则编译为 C 动态库,Python 运行时通过 tree_sitter_python 这类独立 wheel 加载。它暴露的节点类型名随语言而异——Python 里函数定义是 function_definition、类是 class_definition,而在 JavaScript 里则是 function_declaration 和 class_declaration。因此切块器需要为每种语言准备各自的 DEFS 集合。tree-sitter 的容错性让它在代码有语法错误时仍能产出可用语法树,节点上的 has_error 属性可用于识别损坏区域。

两个切块器,同样的 token 预算

安装依赖:py-tree-sitter 0.26.0 需要 Python 3.10 或更高版本,Python 语法作为独立 wheel 发布。嵌入模型选用 BAAI/bge-small-en-v1.5,384 维,最大序列长度 512 token。两个切块器都拿到 480 token 的预算,用嵌入模型自己的分词器计数,这样短头部和特殊 token 也能塞进 512 以内,不会有内容被静默截断——这才是公平的“512-token 块”。

pip install tree-sitter tree-sitter-python \
  sentence-transformers numpy

第一个文件 chunkers.py 从 Chunk 类型开始,embed_text() 会把 # json/decoder.py :: JSONDecoder.raw_decode 这样的路径和符号面包屑放在嵌入文本的第一行。基准切块器 fixed_chunks 按行累加 token,超出预算就发出一个块,然后回退约 64 token 做重叠。它对行尾对齐并做了重叠,比朴素的 token 切分器更宽容一些,但缺少的恰恰是两点:语法感知的边界和符号名。


**BAAI/bge-small-en-v1.5** 是 BAAI 发布的英文通用嵌入模型,384 维,基于 BERT 架构,最大序列长度 512 token。它使用 WordPiece 分词器——这对代码来说并不理想:WordPiece 按子词切分,会把 `snake_case` 和 `camelCase` 拆成无意义的片段,也会把 `raw_decode` 切成 `raw`、`##_`、`decode` 之类。嵌入模型的最大长度意味着超过 512 token 的输入会被静默截断,因此示例中把块预算控制为 480 token,为嵌入文本头部的路径/符号面包屑和特殊 token 留出余量。生产环境应换成代码语料训练的嵌入模型(如 CodeBERT、jina-code 等),并用同一套评测集验证替换效果。
# chunkers.py
from dataclasses import dataclass
from typing import Callable

import tree_sitter_python as tspython
from tree_sitter import Language, Parser

PARSER = Parser(Language(tspython.language()))
DEFS = {"function_definition", "class_definition"}
Count = Callable[[str], int]

@dataclass
class Chunk:
    path: str
    symbol: str  # "JSONDecoder.raw_decode", "<module>", or ""
    start: int   # 1-based first line
    end: int     # 1-based last line, inclusive
    text: str

    def embed_text(self) -> str:
        head = f"# {self.path}"
        if self.symbol:
            head += f" :: {self.symbol}"
        return f"{head}\n{self.text}"

src.split("\n") 而不是 splitlines() 是刻意的:tree-sitter 只按 \n 计行,而 splitlines() 还会在换页符处分割,一些老 Python 文件里有换页符,混用会让第一个换页符之后的行号全部偏移一位。

AST 切块器在同一文件里。block 遍历模块或类体的语句,每个定义交给 define,定义之间的语句(import、常量、类 docstring)收集到 loose 并打包成 <module> 块或按类命名的块。define 在定义能装下时整块发出;装饰器算作定义的一部分,因为 tree-sitter-python 把它们包在 decorated_definition 里。符号路径就是面包屑:JSONDecoder 里的方法得到 JSONDecoder.raw_decode,即使方法体从未出现“JSON decoder”字样,查询也能字面命中。

一个函数比预算还大怎么办

函数级切块在超大定义上会失效。300 行的类或 200 行的函数塞不进 480 token,整个丢给嵌入模型也没用。all-MiniLM-L6-v2 的模型卡写得很直接:“默认情况下,超过 256 个词片的输入文本会被截断。”截断之后的内容对检索不可见,而且没有任何警告。

针对尺寸问题,切块器分三步处理:

过大的类:递归。 define 对类体调用 block,每个方法成为独立块,符号为 Class.method。类签名成为 head,前置到类级松散语句的块上,这样 docstring 和类属性仍然知道自己属于哪个类。

过大的函数:在签名下打包语句。 pack 遍历函数体的顶层语句,贪心合并相邻语句直到下一个会超出预算。每一块都以完整签名开头,包括装饰器。从长函数中间检索到的片段,仍然以 def _make_iterencode(...) 开头。这正是 cAST 中“拆分大节点、合并小兄弟”的合并那一半。

过大的单条语句:回退到窗口。 150 行的 if 块是一条语句。emit 发现它装不下,就只对这一条语句运行行窗口切块器,签名依然放在顶部。cAST 的做法是递归进入语句的子节点,边界更干净;这里的回退是廉价版本,当代码库里这类语句很多时,应换成递归。

Small siblings merge, big nodes split, and every split piece keeps the signature on top.

有两个成本需要知道:头部会吃掉预算,签名长达 15 行的函数,每个片段留给函数体的空间更小;pack 会对增长的跨度反复分词,最坏情况是二次复杂度,几个文件无所谓,单体仓库里应该对每条语句只计一次 token 再累加。

在带标注的查询集上给两者打分

tree-sitter 在源码有语法错误时也能产出有用的结果,所以重构到一半的文件仍然会生成语法树。py-tree-sitter 在每个节点上暴露 has_error,检查根节点,文件严重损坏时对该文件回退到 fixed_chunks,而不是索引垃圾边界。

评测需要一个每个读者都有的语料库——Python 标准库就在你的磁盘上。查询描述代码做什么,大多不使用标识符,就像搜索一个你没写过的代码库。每条标注是一个文件加一个符号路径。eval.py 把六个文件读入,构建两个块列表,recall 在 top-5 结果里用两种方式检查:

  • touched@5:top-5 中是否有块与目标函数有任何重叠。这个指标宽松,也正是 token 窗口切块器表现最好的地方,因为一个装着目标函数三行的块也算命中。
  • whole@5:top-5 中是否有一个块完整包含整个函数,这才是 LLM 回答所必需的。

两者的差距,就是“函数体丢了名字”这个问题被量化后的结果。文章刻意不打印结果:你的 Python 版本会改变标准库文件,12 条查询只是冒烟测试,不是基准。跑起来读你自己的数字。有些标注比 480 token 预算还长,AST 切块器也会拆分它们,两个切块器都无法在这些标注上拿到 whole,namedtuple 和 _make_iterencode 是明显的例子。要做真正的决策,就针对自己的仓库写 50 到 100 条查询,来自代码评审或 Slack 里人们真实问过的问题,并把它和切块器一起纳入版本控制。

Recall@K 是检索评测的常用指标:在返回的前 K 个结果中,只要含有与标准答案相关的条目,就记为一次命中;Recall@5 即统计所有查询里 top-5 命中目标的比例。示例把命中拆成两个更细的粒度:touched@5 要求 top-5 中任何块与目标函数有任意行重叠即算命中,宽松且对固定窗口切块器友好——因为一个恰好包含目标函数三行的块也算命中;whole@5 要求 top-5 中至少有一个块完整包含整个函数,这才是 LLM 能基于它生成准确回答的前提。两者的差距直接量化了“函数体与签名/符号名被切散”的代价,是选择切块策略时最有决策价值的指标。

指向你的仓库之前

从标准库演示到可以索引单体仓库,有四处改动:

更多语言。 tree-sitter 覆盖了大多数你会遇到的语法,但节点类型名随语法而异。function_definition 是 Python 的写法;查每个语法的 node-types.json,给每种语言各自的 DEFS 集合。想要 Python、Java、C# 和 TypeScript 的维护版本,astchunk 已经做到了。

解析错误。 如上所述,用 has_error 检查根节点,损坏文件回退到固定切块。

代码感知的嵌入模型。 bge-small-en-v1.5 是通用英语模型,WordPiece 分词器会把 snake_case 和 camelCase 撕成碎片。演示够用;生产环境应该用同一个 eval.py 在标注集上比较代码训练的嵌入模型,这正是标注集的用途。

向量旁边加关键词检索。 精确标识符、错误码和配置键是稠密检索最弱的地方。把 symbol 字段也索引进关键词索引并融合结果,包含 raw_decode 的查询应该直接找到 raw_decode,不必让嵌入模型去猜。

检索器只能返回切块器保持在一起的东西,而代码让这件事一目了然。检索和重排序这两端的阶段,以及生产 RAG 的切块模式,在 RAG Pocket Guide 里有更完整的讨论。

#tree-sitter#代码检索#RAG切块#Code Search RAG#AST切块#代码嵌入#Python代码解析#chunking策略
分享:

相关推荐