第78题:为什么代码不能只按固定 token 切块?

1. 核心回答
代码 RAG 不能只依赖固定 token 数量切块,核心原因是:token 长度只描述文本长度,不描述代码的语法边界和程序依赖关系。
例如每 512 token 强制切一次,切割位置可能正好落在:
- 一个函数内部;
- 函数签名与函数体之间;
if/else、try/catch等控制结构内部;- 类定义与成员方法之间;
- 调用方和被调用函数之间;
- source 和 sink 之间。
这样得到的 chunk 在文本长度上满足要求,但可能缺少完成代码理解所需的上下文。
因此,更合理的方案是:
使用 AST / symbol boundary 构造语义相对完整的基础 chunk,再通过 call graph、data flow、父级作用域和跨文件依赖补充相关上下文,同时保留 token 上限控制。
2. 固定 token 切块为什么会破坏语法完整性
假设代码如下:
def verify_user(user):
if user.is_admin:
return check_permission(user)
return False
如果固定 token 边界恰好把代码切成:
Chunk A:
def verify_user(user):
if user.is_admin:
Chunk B:
return check_permission(user)
return False
两个 chunk 单独看都缺少完整语义。
Chunk A 不知道最终执行了什么操作。
Chunk B 缺少:
- 函数名称;
- 参数;
- 条件判断;
- 所属作用域。
Embedding 模型得到的表示也会受到影响,因为输入本身已经失去完整的程序结构。
Tree-sitter 等解析器可以把源代码解析成语法树,并识别:
- function;
- method;
- class;
- statement;
- expression;
- call expression;
等结构。
因此可以优先在这些自然语法边界上切分。
3. 为什么只按函数切块也不够
进一步使用:
一个函数 = 一个 chunk
能够解决一部分语法完整性问题,但仍然存在跨函数依赖。
例如:
def handle_request(req):
data = req.get("cmd")
execute(data)
另一个文件中:
def execute(cmd):
os.system(cmd)
如果只检索 handle_request(),模型看到:
execute(data)
但不知道 execute() 最终调用了:
os.system(cmd)
如果只检索 execute(),模型又不知道参数 cmd 来自外部请求。
安全分析真正关心的可能是:
HTTP Request
↓
req.get("cmd")
↓
execute(data)
↓
os.system(cmd)
也就是:
Source→Propagation→Sink Source \rightarrow Propagation \rightarrow Sink Source→Propagation→Sink
这条数据流跨越了多个函数。
因此函数边界可以作为基础切块单位,但还需要保存函数之间的关系。
4. 漏洞检测为什么特别依赖跨块关系
很多漏洞需要联合多个代码位置才能判断。
例如命令注入、SQL 注入、路径遍历等问题通常需要同时判断:
- 数据从哪里进入;
- 中间经过哪些函数;
- 是否经过校验或 Sanitizer;
- 最终进入什么危险操作。
可以抽象成:
Source→Transform→Sanitizer?→Sink Source \rightarrow Transform \rightarrow Sanitizer? \rightarrow Sink Source→Transform→Sanitizer?→Sink
如果:
Source
和:
Sink
位于不同函数甚至不同文件,固定 token chunk 很容易把证据分散到不同检索单元。
CodeQL 的安全分析也明确区分:
- Local Data Flow;
- Global Data Flow;
- Taint Tracking;
- Inter-procedural Data Flow。
局部数据流主要考虑单个函数内部。
全局数据流需要继续跨函数、调用和对象属性追踪值的传播。
这说明代码安全分析中的很多语义天然具有跨 chunk 特征。
5. 代码还有哪些跨文件依赖
除了数据流,还需要考虑:
5.1 调用关系
例如:
controller.py
↓
service.py
↓
repository.py
当前代码的真实语义可能依赖调用链上的其他函数。
5.2 类型定义
例如代码中出现:
user.role
理解 role 的含义可能需要找到:
class User
的定义。
5.3 Import 和依赖
例如:
from auth import verify
真正的行为由 auth.verify() 决定。
5.4 配置和常量
例如:
if mode == ADMIN_MODE:
需要找到:
ADMIN_MODE = ...
才能确定条件含义。
5.5 接口实现
例如:
storage.save(data)
实际调用对象可能由运行时类型决定。
因此代码检索需要同时保存:
- symbol definition;
- reference;
- caller-callee;
- import;
- inheritance;
- type relation;
- data flow;
等关系。
6. 更合理的代码切块方法
我会采用一种结构感知的层级切块策略。
首先解析代码:
Source Code
↓
Parser / Tree-sitter
↓
AST / Symbol Table
然后识别:
- class;
- function;
- method;
- declaration;
- module;
- import。
基础 chunk 可以优先按照完整 symbol 构造。
例如:
Chunk 1 = verify_user()
Chunk 2 = execute()
Chunk 3 = User class
同时为每个 chunk 保存 metadata:
repository
file_path
language
class_name
function_name
start_line
end_line
parent_symbol
imports
callers
callees
version
这样检索到一个函数之后,可以继续沿结构关系扩展上下文。
7. 函数超过模型长度怎么办
结构边界也不能完全取代 token 限制。
如果一个函数有 5000 token,而检索系统的目标 chunk 大小只有 512 或 1024 token,就需要继续拆分。
可以采用:
AST Boundary+Token Budget AST\ Boundary + Token\ Budget AST Boundary+Token Budget
的组合策略。
例如首先获得:
Function
├── initialization
├── validation
├── processing loop
└── return
然后在 AST statement/block 边界上继续递归拆分。
最终满足:
∣chunki∣≤Lmax |chunk_i|\leq L_{\max} ∣chunki∣≤Lmax
同时尽量保持完整的语法结构。
因此实际目标是:
在 token 长度约束下最大程度保持代码语义完整。
8. 为什么 chunk 也不能无限大
把整个文件甚至整个仓库放入一个 chunk 同样会产生问题。
Chunk 很大时通常会增加:
- Embedding 中无关信息;
- 检索噪声;
- 上下文 token 成本;
- 推理延迟;
- reranking 成本。
例如一个 3000 行文件中只有一个函数与查询相关。
如果整个文件作为一个 retrieval unit:
relevant code = 50 lines
irrelevant code = 2950 lines
检索虽然命中了正确文件,但生成模型收到大量无关内容。
因此存在一个权衡:
Chunk Too Small⇒Context Incomplete Chunk\ Too\ Small \Rightarrow Context\ Incomplete Chunk Too Small⇒Context Incomplete
Chunk Too Large⇒Noise+Cost Chunk\ Too\ Large \Rightarrow Noise + Cost Chunk Too Large⇒Noise+Cost
合理 chunk 应位于两者之间。
9. 一个实用的层级检索方案
我会设计两级甚至三级结构。
9.1 第一级:小粒度检索
索引:
- function;
- method;
- class;
- declaration。
使用 BM25 或 Embedding 找到候选 symbol。
例如:
Query
↓
Top-K functions
这一层重点提高检索精度。
9.2 第二级:结构扩展
找到目标函数后,根据代码关系扩展:
Current Function
├── Caller
├── Callee
├── Definition
├── Type
└── Data-flow Neighbor
例如检索到了:
execute(cmd)
系统继续加入:
handle_request()
sanitize()
os.system()
从而恢复完整调用和数据流上下文。
9.3 第三级:上下文装配
最终根据 token budget 选择最有价值的证据:
C={c1,c2,…,ck} C= \{c_1,c_2,\ldots,c_k\} C={c1,c2,…,ck}
满足:
∑i=1k∣ci∣≤ContextBudget \sum_{i=1}^{k}|c_i| \leq ContextBudget i=1∑k∣ci∣≤ContextBudget
优先保留:
- 当前 symbol;
- 直接 caller/callee;
- source-sink 路径;
- 关键定义;
- 必要配置。
这样检索和生成使用的上下文可以采用不同粒度。
10. AST、Call Graph 和 Data Flow 分别解决什么问题
三者解决的问题不同。
| 结构 | 主要解决的问题 |
|---|---|
| AST | 当前代码的语法结构 |
| Symbol Table | 定义与引用 |
| Call Graph | 函数之间谁调用谁 |
| CFG | 一个函数内部控制如何流转 |
| DFG / Data Flow | 数据从哪里来、流向哪里 |
| Taint Flow | 不可信数据是否流到危险位置 |
例如固定 token chunk 只能看到:
A B C D E
AST 能告诉系统:
Function
├── Condition
└── Call
Call Graph 能告诉系统:
Function A
↓
Function B
Data Flow 能告诉系统:
user_input
↓
cmd
↓
system()
所以对于代码理解和漏洞检测,结构信息具有直接价值。
11. 结构化切块一定比固定 token 切块好吗
这个结论仍然需要实验验证。
近期针对 Retrieval-Augmented Code Completion 的受控实验比较了:
- Function chunking;
- Declaration chunking;
- Sliding Window;
- cAST。
实验表明 chunking strategy 会显著影响最终结果。
同时有一个很重要的发现:
Function chunking 在该实验的 RepoEval 设置中并没有取得最优结果。
Sliding Window 和基于 AST 的 cAST 在成本—质量权衡上表现更好。
因此工程上不应该直接规定:
function chunking = 最优方案
更合理的方法是建立候选策略:
Fixed Token / Sliding Window
Function
Declaration
AST-aware
Hierarchical
然后在自己的任务数据上比较。
12. 怎么通过实验选择 chunk 策略
我会固定:
- Retriever;
- Embedding Model;
- Generator;
- Top-K;
- Context Budget;
- 数据集;
- Query。
只改变 chunking strategy。
然后首先看检索指标:
12.1 Recall@K
$$
Recall@K
\frac{
\text{Top-K 中找到的相关证据}
}{
\text{全部相关证据}
}
$$
12.2 MRR
如果最关键证据越早出现,MRR 越高。
12.3 Context Precision
检查放入上下文中的 chunk 有多少真正相关。
随后还要看端到端指标,例如:
- 漏洞检测 Recall / Precision / F1;
- 代码生成 Pass@1;
- Patch Success Rate;
- 解释正确率。
同时报告系统成本:
- 平均 chunk 数;
- 索引大小;
- Embedding 成本;
- Retrieval latency;
- Prompt tokens;
- End-to-end latency。
最终依据:
$$
Quality
Cost
$$
的权衡选择方案。
13. 一个针对漏洞检测的具体例子
假设存在:
request.py
def handle(req):
cmd = req["cmd"]
run(cmd)
以及:
shell.py
def run(x):
os.system(x)
固定 token 切块可能形成:
Chunk A:
cmd = req["cmd"]
Chunk B:
run(cmd)
Chunk C:
def run(x):
Chunk D:
os.system(x)
单独检索任何一个 chunk,都可能不足以证明命令注入。
结构化系统可以先识别:
req["cmd"] req["cmd"] req["cmd"]
是 Source。
再根据:
handle()→run() handle() \rightarrow run() handle()→run()
的调用关系连接两个函数。
继续根据 Data Flow 建立:
req["cmd"]→cmd→x→os.system(x) req["cmd"] \rightarrow cmd \rightarrow x \rightarrow os.system(x) req["cmd"]→cmd→x→os.system(x)
最终得到完整证据链。
这种情况下,chunk 的作用是提供可检索的局部代码单元;程序结构负责重新连接这些局部单元。
14. 面试时可以压缩成下面这段
代码不能只按固定 token 数切块,因为 token 边界和程序语义边界没有必然关系。固定长度切分可能把一个函数、控制结构甚至 source-sink 数据流直接截断,导致每个 chunk 都缺少判断所需的上下文。
我的做法会先用 Tree-sitter 之类的 Parser 获得 AST 和 symbol boundary,以函数、类、方法、声明等结构作为基础检索单元。对于过大的 symbol,再在 AST statement 或 block 边界内按照 token budget 递归切分。同时给 chunk 保存 caller、callee、definition、import、data-flow 和文件版本等 metadata。
检索阶段先用小粒度 chunk 做匹配,再根据 call graph、data flow 和父级作用域扩展上下文。这样可以兼顾检索精度和代码语义完整性。
同时我不会预设 AST 或函数切块一定最好。我会固定 Retriever、Embedding、Generator 和 Context Budget,对比 Sliding Window、Function、Declaration 和 AST-aware chunking,使用 Recall@K、MRR、端到端任务指标、延迟和 token 成本共同选择最终策略。
15. 来源
- Tree-sitter Documentation — Tree-sitter 可以将源码解析为 concrete syntax tree,并通过 syntax node 表示函数、表达式等程序结构。
- Guo et al., GraphCodeBERT: Pre-training Code Representations with Data Flow, ICLR 2021 — 指出单纯把代码视为 token 序列会忽略代码固有结构,并通过 data flow 建模变量之间的语义关系。
- Zhang et al., RepoCoder: Repository-Level Code Completion Through Iterative Retrieval and Generation, 2023 — 说明 repository-level 任务需要利用分散在不同文件中的相关上下文。
- Cheng et al., Dataflow-Guided Retrieval Augmentation for Repository-Level Code Completion (DraCo), 2024 — 使用代码实体和扩展数据流建立 repository-specific context graph,用于跨文件上下文检索。
- Zhang et al., cAST: Enhancing Code Retrieval-Augmented Generation with Structural Chunking via Abstract Syntax Tree, 2025 — 研究 AST-aware structural chunking,并报告其对代码检索和生成的提升。
- Wu et al., How Does Chunking Affect Retrieval-Augmented Code Completion? A Controlled Empirical Study, 2026 — 系统比较 Function、Declaration、Sliding Window 和 cAST,表明 chunking 策略显著影响代码 RAG,且不存在“函数切块天然最优”的普遍结论。
- CodeQL Documentation — Data Flow Analysis / Path Queries — 说明 Local Data Flow、Global Data Flow、Taint Tracking 以及 source-to-sink 路径分析在安全分析中的作用。

1127

被折叠的 条评论
为什么被折叠?



