一个几乎所有人都会犯的错
要把代码切成块(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.PaymentIntent、create()这类词汇,和"Stripe charge"语义高度贴合。三种策略都轻松命中它。calculate_order_total就麻烦了。它的职责是"累加商品价格、打折、算税",签名和实现里全是sum、discount、tax,压根没有"payment"或"Stripe"的影子。在向量空间里,它离"create Stripe charge"这个查询相当远。
关键区别就在这里:
AST 分割 :calculate_order_total 被切成一个独立的 chunk,孤零零地站在向量空间里。它离查询远,所以检索不到------AST 只拿到 0.50(命中了 2 个中的 1 个)。
固定行 / 文件级分割 :calculate_order_total 和 create_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 这条边,检索流程就能这样补救:
- 查询
process payment and create Stripe charge,向量检索命中create_payment_intent。 - 顺着调用图,发现
create_payment_intent调用了calculate_order_total。 - 把
calculate_order_total一起召回。
这就用调用图这条确定性的结构边,主动实现了大 chunk 靠运气才有的 free-rider 效应------而且不用牺牲 AST 的 precision。语义上相关但向量距离远的函数,靠调用关系被拉回来。
这恰恰是向量检索的天花板所在:calculate_order_total 和"Stripe charge"的语义鸿沟,靠任何 Embedding 策略都填不平(这个结论第 03 篇已经验证过了)。要跨过这道鸿沟,得靠代码本身的结构信息------调用图、导入关系、模块归属。
这也正是本系列下一篇要展开的主题:向量检索 vs 知识图谱。当语义相似度失灵时,代码的结构关系才是那根救命稻草。
总结
- 反直觉结论:AST 函数级分割 Recall@5 最低(0.958),固定行和文件级都是满分(1.000)。 但这个"输"是假象。
- 胜负手是 free-rider 效应。 唯一分化的 Q8 里,
calculate_order_total语义上离查询很远,AST 把它切成孤岛所以漏检;大 chunk 里它和create_payment_intent捆在一起,搭便车被召回。 - 文件级的满分是作弊------它的 precision 为零。 只有 1 个 chunk,无法定位到具体函数,本质是把检索甩给 LLM。
- 固定行的满分是巧合。 数据集函数短且密集,恰好撞上了 chunk 大小。换成函数动辄上百行的大型项目,固定行会把函数切成残片。
- 规模决定选择: 小库文件级凑合,中库固定行+overlap,大库 AST。这个 266 行的实验太小,掩盖了 precision 的真实差异。
- 最佳实践:AST 函数级 + 元数据 + 调用图。 用调用图这条确定性的结构边,主动补回大 chunk 靠运气才有的 free-rider 效应,同时保住 AST 的 precision。
参考资料
- 本系列完整 Demo 代码:codebase-kb-04-chunking
欢迎访问 PrimeSkills ------ 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页