代码库知识库系列(04):三种 Chunking 策略对比——AST 精确分割反而输了?

一个几乎所有人都会犯的错

要把代码切成块(chunk)喂给向量模型,最"专业"的做法是什么?

大多数工程师的第一反应是:按 AST(抽象语法树)切,一个函数一个 chunk。理由很硬------函数是最自然的语义单元,AST 精确对齐了语义边界,不会把一个函数拦腰截断。相比之下,按固定行数硬切(比如每 20 行一块)显得又土又蠢,会把函数切得七零八落。

听起来无懈可击。可实验数据把这个直觉打了个响亮的耳光。

在同一份 266 行的 Python 代码上,我跑了三种 Chunking 策略。结果是:AST 函数级分割的得分最低。 那个"又土又蠢"的固定行分割,反而拿了满分。

这不是随机噪声,背后有一个非常值得挖的机制。


三种策略

数据集: 266 行 Python 代码,涵盖认证、数据库、缓存、支付、通知 5 个模块,共 27 个函数。Embedding 模型统一用 bge-large-zh-v1.5,评估用 12 个自然语言查询。

三种切法:

策略 1:固定行分割(每 20 行,overlap=3)

不管代码结构,每 20 行切一刀,相邻 chunk 重叠 3 行防止边界信息丢失。

yaml 复制代码
Chunk 1: 第 1-20 行
Chunk 2: 第 18-37 行   ← 和上一块重叠 3 行
Chunk 3: 第 35-54 行
...

策略 2:文件级分割(整个文件一个 chunk)

简单粗暴,整份代码就是一个 chunk。

yaml 复制代码
Chunk 1: 第 1-266 行   ← 全塞进去

策略 3:AST 函数级分割(一个函数一个 chunk)

用 Python 的 ast 模块解析语法树,按函数定义精确切割。

python 复制代码
Chunk 1: def validate_jwt_token(...)   第 9-18 行
Chunk 2: def hash_password(...)        第 20-28 行
Chunk 3: def create_payment_intent(...) 第 ...
...

先看一眼固定行分割"土"在哪里。以 validate_jwt_token 这个函数为例(第 9-18 行,共 10 行):

arduino 复制代码
函数 'validate_jwt_token'(第 9-18 行,10 行)
被切进 2 个 chunk:
  Chunk 第 1-20 行:  包含函数第 9-18 行(10/10 行)
  Chunk 第 18-37 行: 包含函数第 18-18 行(1/10 行)

问题:没有任何一个 chunk 包含完整的函数。
一个关于 'validate_jwt_token' 的查询会检索到一段残缺的实现。

看到没?函数的最后一行(第 18 行)被切到了下一个 chunk 里。这正是固定行分割最被诟病的地方------它不认识函数边界,说切就切。

按理说,这种残缺应该会拖累检索效果。AST 分割每个函数都完完整整,怎么看都该赢。


运行结果

kotlin 复制代码
Strategy                      Chunks   AvgLen      R@3      R@5  vs AST R@5
──────────────────────────── ───────  ───────  ───────  ───────  ──────────
1_fixed_lines_20                  16      755    0.917    1.000      +0.042
2_file_level                       1    10270    1.000    1.000      +0.042
3_ast_function                    27      356    0.889    0.958        base

反转发生了:

  • 固定行分割:Recall@5 = 1.000(满分)
  • 文件级分割:Recall@5 = 1.000(满分)
  • AST 函数级分割:Recall@5 = 0.958(最低)

那个会把函数切断的"土办法"赢了,那个精确对齐语义边界的"专业做法"输了。

12 道查询里,11 道三种策略全部满分,唯一的分化出现在第 8 题:

arduino 复制代码
Query                                               Fixed    File     AST
────────────────────────────────────────────────── ──────  ──────  ──────
process payment and create Stripe charge             1.00    1.00    0.50 ←
(其余 11 道题,三种策略全部 1.00)

一道题定胜负。这道题上,AST 只拿到 0.50,固定行和文件级都是 1.00。

要理解这场反转,必须搞清楚这一道题上到底发生了什么。


free-rider 效应:搭便车的函数

查询是 process payment and create Stripe charge(处理支付并创建 Stripe 扣款)。

这道题的 ground truth 有两个相关函数:create_payment_intent(创建支付意图)和 calculate_order_total(计算订单总价)。

  • create_payment_intent 里有 stripe.PaymentIntentcreate() 这类词汇,和"Stripe charge"语义高度贴合。三种策略都轻松命中它。
  • calculate_order_total 就麻烦了。它的职责是"累加商品价格、打折、算税",签名和实现里全是 sumdiscounttax,压根没有"payment"或"Stripe"的影子。在向量空间里,它离"create Stripe charge"这个查询相当远。

关键区别就在这里:

AST 分割calculate_order_total 被切成一个独立的 chunk,孤零零地站在向量空间里。它离查询远,所以检索不到------AST 只拿到 0.50(命中了 2 个中的 1 个)。

固定行 / 文件级分割calculate_order_totalcreate_payment_intent 恰好落在同一个 chunk 里(它们在源码里相邻)。当查询命中 create_payment_intent 时,整个 chunk 被一起检索出来,calculate_order_total 就跟着"搭了便车"------这就是所谓的 free-rider 效应(搭便车效应)。

图片:向量空间示意图。左侧 AST 分割,calculate_order_total 是一个孤立的点,离 query 很远,检索不到;右侧 file/fixed 分割,calculate_order_total 和 create_payment_intent 被同一个方框圈在一起,query 命中方框时两个函数一起被拉出来。

用一个类比来讲:

AST 分割像超市里每样商品单独摆放、单独扫码。你搜"啤酒",只会找到啤酒。 大 chunk 像把啤酒和尿布捆成一个促销套装。你搜"啤酒"命中套装,尿布也一起进了购物车------哪怕你压根没搜尿布。

在这个 266 行的小数据集上,"套装"恰好把语义相邻的函数捆对了,所以捎带命中了本来找不到的 calculate_order_total。AST 因为切得太干净,反而失去了这个"意外收获"。

这就是反转的全部真相:不是 AST 分割更差,而是大 chunk 靠 free-rider 效应蒙对了一道题。


别急着给固定行分割颁奖

看到这里,很容易得出结论:"那以后都用固定行/文件级不就完了?" 慢着,这个结论错得离谱。原因藏在这张表被刻意忽略的一列里。

文件级分割的 Recall@5 是满分,但它的 precision 是零。

想想文件级分割的机制:整份代码只有 1 个 chunk。任何查询检索出来的,永远是同一个 chunk------也就是整份 266 行代码。Recall@5 测的是"top-5 里有没有正确答案",既然只有 1 个 chunk 且它包含所有函数,那当然永远命中。

但这有什么用?你问"JWT 验证在哪",它把整个文件甩给你。你还得自己在 266 行里翻。检索系统的意义是定位,文件级分割等于没定位。 它的满分是作弊得来的。

固定行分割也没那么香。这个数据集的函数又短又密集,平均函数长度小,20 行的 chunk 加 3 行 overlap,恰好让很多函数完整落在一个 chunk 里,甚至让相邻函数搭上便车。这是数据集特征撞上了 chunk 大小的巧合,不是固定行分割的本事。

换一个真实的大型项目试试:函数动辄上百行,一个 20 行的 chunk 连函数的一半都装不下。那个开头演示的 validate_jwt_token 被切断的问题会成为常态------你检索到的永远是函数的残片,signature 在一个 chunk、核心逻辑在另一个 chunk、异常处理在第三个 chunk。到那时,固定行分割的 Recall 会崩得很惨。

这个实验的规模(266 行)太小,恰好掩盖了 precision 的差异,让两个"作弊选手"看起来赢了。


各策略到底什么时候适用

把规模因素放进来,结论就清晰了:

小代码库(< 1000 行) 文件级可以凑合用------反正全塞进 LLM 的上下文窗口也不贵。但要清楚它 precision 为零,本质是"把检索甩锅给 LLM"。适合"代码少到可以整个丢给模型"的场景。

中等代码库(1000 - 1 万行) 固定行 + 适当 overlap 往往够用,工程实现也最简单(不用解析 AST)。关键是 chunk 大小要匹配你的平均函数长度------理想情况是让大多数函数完整落在一个 chunk 里。overlap 用来兜底边界。

大型代码库(> 1 万行) AST 函数级的 precision 优势才真正显现。当代码量大到不可能全塞进上下文,你需要精确定位到"就是这个函数",AST 分割的每个 chunk 都是一个完整、独立、可跳转的语义单元。这个实验数据集太小,把 AST 的这个核心优势完全掩盖了。

一句话总结:Recall 看着热闹,但生产环境里 precision 才是决定体验的那个指标,而 precision 恰恰是这个小实验测不出来的。


工程最佳实践:AST + 元数据 + 调用图

那么大型项目的正解是什么?不是在三种策略里二选一,而是用 AST 分割拿到 precision,再用元数据和调用图把 free-rider 效应主动补回来。

回想一下 AST 输在哪:它把 calculate_order_total 切成孤岛,丢掉了"它和 create_payment_intent 在同一个支付流程里"这个上下文。大 chunk 是靠物理相邻碰巧保留了这个上下文。我们完全可以显式地把它加回来:

python 复制代码
{
    "content": func_body,                    # AST 切出的完整函数体,喂给 Embedding
    "metadata": {
        "name": "calculate_order_total",
        "module": "payment",                 # 模块名:支持按模块过滤
        "file": "services/payment.py",
        "line_start": 42,
        "line_end": 55,
        "called_by": ["create_payment_intent"],  # 调用图:谁调用了我
        "calls": ["get_tax_rate", "apply_discount"],
    }
}

有了 called_by 这条边,检索流程就能这样补救:

  1. 查询 process payment and create Stripe charge,向量检索命中 create_payment_intent
  2. 顺着调用图,发现 create_payment_intent 调用了 calculate_order_total
  3. calculate_order_total 一起召回。

这就用调用图这条确定性的结构边,主动实现了大 chunk 靠运气才有的 free-rider 效应------而且不用牺牲 AST 的 precision。语义上相关但向量距离远的函数,靠调用关系被拉回来。

这恰恰是向量检索的天花板所在:calculate_order_total 和"Stripe charge"的语义鸿沟,靠任何 Embedding 策略都填不平(这个结论第 03 篇已经验证过了)。要跨过这道鸿沟,得靠代码本身的结构信息------调用图、导入关系、模块归属。

这也正是本系列下一篇要展开的主题:向量检索 vs 知识图谱。当语义相似度失灵时,代码的结构关系才是那根救命稻草。


总结

  1. 反直觉结论:AST 函数级分割 Recall@5 最低(0.958),固定行和文件级都是满分(1.000)。 但这个"输"是假象。
  2. 胜负手是 free-rider 效应。 唯一分化的 Q8 里,calculate_order_total 语义上离查询很远,AST 把它切成孤岛所以漏检;大 chunk 里它和 create_payment_intent 捆在一起,搭便车被召回。
  3. 文件级的满分是作弊------它的 precision 为零。 只有 1 个 chunk,无法定位到具体函数,本质是把检索甩给 LLM。
  4. 固定行的满分是巧合。 数据集函数短且密集,恰好撞上了 chunk 大小。换成函数动辄上百行的大型项目,固定行会把函数切成残片。
  5. 规模决定选择: 小库文件级凑合,中库固定行+overlap,大库 AST。这个 266 行的实验太小,掩盖了 precision 的真实差异。
  6. 最佳实践:AST 函数级 + 元数据 + 调用图。 用调用图这条确定性的结构边,主动补回大 chunk 靠运气才有的 free-rider 效应,同时保住 AST 的 precision。

参考资料


欢迎访问 PrimeSkills ------ 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

更多实用知识和有趣产品,欢迎访问我的个人主页

相关推荐
宋哥转AI2 小时前
深入理解 AI Agent 02|混合检索与重排序实战:BM25、RRF 与 Cross-Encoder 的工程实践
人工智能
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-08-01
人工智能·深度学习·神经网络·搜索引擎·百度
小燕子~~2 小时前
PS 怎么调整图片尺寸?4 种方法避免拉伸变形与放大失真
图像处理·人工智能·ai·aigc·photoshop
小K讲AI营销2 小时前
AI 链定价逻辑切换:用“估值透支“框架重估一个月跌三成
人工智能
龙山云仓2 小时前
八一亮剑 · 造化进行时(AI-类人)
人工智能
小赵AI手记3 小时前
技术拆解(十五)具身智能:RT-2为什么能听懂“灭绝动物”?VLM知识如何变成动作
人工智能·机器人
就是一顿骚操作3 小时前
全球 AI 大事件新闻汇总 2026-08-01
大数据·人工智能
阿童木写作3 小时前
Python实现亚马逊商品图批量智能抠图教程
人工智能·python
EQUINOX13 小时前
【论文精读】| Qwen-VL
人工智能