Agent CLI深水区 - 搜索系统与代码上下文 · AI Agent
Agent CLI 深水区 08:搜索系统与代码上下文
为什么搜索工具比读文件更重要
真实项目里,Agent 不知道该读哪个文件。它需要先定位。
搜索系统通常包括:
- 文件名搜索:glob。
- 正文搜索:grep。
- 结构搜索:AST / symbol。
- Git 状态:改动文件。
- 最近文件:会话上下文。
- 语义搜索:embedding / RAG。
没有搜索,Agent 只能瞎猜路径。
搜索工具分层
GlobTool
代码块收起展开
// GlobInput = 文件名/路径模式搜索的输入。
// 适合“我大概知道文件名长什么样”,例如 **/*config*.ts。
type GlobInput = {
pattern: string; // glob 表达式,例如 "src/**/*.ts"。
limit?: number; // 最多返回多少条,避免结果撑爆上下文。
};
// globTool = 文件名搜索工具。
// 职责:在 workspace 内找文件,并过滤掉 node_modules、dist 等无意义目录。
export async function globTool(input: GlobInput, ctx: ToolContext): Promise<ToolResult> {
// limit 由模型传入,但程序要设置上限,防止模型请求 100000 条。
const limit = Math.min(input.limit ?? 100, 500);
// cwd 限制在当前项目;ignore 避免搜依赖、Git 内部目录、构建产物。
const matches = await glob(input.pattern, {
cwd: ctx.cwd,
ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"]
});
// 只返回前 limit 条,但 metadata 里保留总数。
const limited = matches.slice(0, limit);
return {
summary: `Found ${matches.length} files, returning ${limited.length}.`,
content: limited.join("\n"),
metadata: {
total: matches.length,
returned: limited.length
}
};
}关键点:
- 忽略
node_modules、.git、构建产物。 - 限制返回数量。
- 返回 total 和 returned。
GrepTool
代码块收起展开
// GrepInput = 正文搜索的输入。
// 适合“我知道某个函数名、错误信息、配置 key 或可见文案”。
type GrepInput = {
pattern: string; // 要搜索的文本或正则。
include?: string; // 限定文件范围,例如 "src/**/*.ts"。
limit?: number; // 最多返回多少条。
};
// grepTool = 正文搜索工具。
// 真实实现通常调用 ripgrep,因为它快、支持 ignore、输出可控。
export async function grepTool(input: GrepInput, ctx: ToolContext): Promise<ToolResult> {
// runRipgrep 是对 rg 的封装,负责执行命令、解析输出、截断结果。
const result = await runRipgrep({
pattern: input.pattern,
include: input.include,
cwd: ctx.cwd,
limit: Math.min(input.limit ?? 100, 500)
});
return {
summary: `Found ${result.total} matches, returning ${result.items.length}.`,
// formatGrepMatches 应输出 path:line:text,方便模型继续 read_file。
content: formatGrepMatches(result.items),
metadata: {
total: result.total, // 实际命中总数。
returned: result.items.length // 返回给模型的条数。
}
};
}格式建议:
代码块收起展开
src/queryEngine.ts:42: const response = await callModel(...)
src/tool.ts:80: export async function executeToolCall(...)这种格式模型容易继续调用 read_file。
搜索结果排序
搜索结果不应该原样全塞给模型。要排序。
评分因素:
| 因素 | 说明 |
|---|---|
| 文件名匹配 | queryEngine.ts 比随机文件重要 |
| 路径层级 | src/ 通常比 dist/ 重要 |
| 最近修改 | 当前任务相关概率更高 |
| Git changed | 已改文件优先 |
| match count | 命中多可能相关 |
| 文件大小 | 太大文件降低优先级 |
代码块收起展开
// scoreSearchResult = 给搜索结果打分。
// 分数越高,越应该优先读这个文件。
function scoreSearchResult(item: SearchResult, query: string) {
let score = 0;
// 文件名直接包含 query,通常比正文偶然命中更可靠。
if (item.path.toLowerCase().includes(query.toLowerCase())) score += 5;
// src/ 下的源码通常比 dist/、docs/ 更接近可修改对象。
if (item.path.startsWith("src/")) score += 3;
// test 文件对理解预期行为很重要,所以也加分。
if (item.path.includes("test")) score += 1;
// 当前 Git 已改文件要优先,因为用户可能正在围绕它工作。
if (item.gitChanged) score += 4;
// 命中越多越可能相关,但最多加 10,避免大文件靠重复刷分。
score += Math.min(item.matchCount, 10);
// 太大的文件通常不适合直接塞给模型,先降权。
if (item.sizeBytes > 200_000) score -= 5;
return score;
}CodeContext
读文件后,不一定要把整文件塞给模型。可以构造代码上下文:
代码块收起展开
// CodeContext = 读完文件后整理出的结构化上下文。
// 它比“整文件文本”更适合喂给模型,因为它保留了路径、语言、符号和切片。
type CodeContext = {
path: string; // 文件路径。
language: string; // 编程语言,例如 ts/java/python,用来决定解析策略。
imports: string[]; // import/require/use 列表,帮助模型理解依赖。
symbols: string[]; // 文件里定义的类、函数、接口等符号名。
relevantSlices: Array<{
startLine: number; // 切片开始行。
endLine: number; // 切片结束行。
text: string; // 这段源码。
}>;
};这比纯文本更结构化。
行号读取
Agent 经常只需要某个范围。
代码块收起展开
// ReadFileInput = 读取文件工具的输入。
// startLine/endLine 让模型只读关键范围,节省 token。
type ReadFileInput = {
path: string; // 要读取的文件路径,必须在 workspace 内。
startLine?: number; // 可选:从第几行开始,1-based。
endLine?: number; // 可选:读到第几行,1-based。
};
// readFileTool = 带行号的文件读取工具。
// 带行号的输出能帮助后续 edit/diff/code review 精确定位。
export async function readFileTool(input: ReadFileInput, ctx: ToolContext) {
// 路径解析和越界检查。
const abs = resolveWorkspacePath(ctx.cwd, input.path);
// 教学版默认 UTF-8。真实系统还要处理大文件、二进制、编码。
const text = await readFile(abs, "utf8");
const lines = text.split(/\r?\n/);
// 没传行号就读全文件;真实系统要对全文件读取设置大小上限。
const start = input.startLine ?? 1;
const end = input.endLine ?? lines.length;
// slice 用 0-based 下标,所以 start - 1。
// 输出时重新加回真实行号。
const selected = lines
.slice(start - 1, end)
.map((line, index) => `${start + index}: ${line}`)
.join("\n");
return {
summary: `Read ${input.path}:${start}-${end}`,
content: selected,
metadata: {
totalLines: lines.length, // 文件总行数,方便模型判断是否还要继续读。
startLine: start, // 本次读取开始行。
endLine: end // 本次读取结束行。
}
};
}带行号的好处:
- 后续 edit 更精确。
- 用户能定位。
- 代码审查更方便。
Git Context
Agent 做代码任务时,必须知道工作区状态。
代码块收起展开
// GitContext = 当前工作区的 Git 状态摘要。
// 它的意义是提醒模型:哪些文件是用户已经改过的,不能随便覆盖。
type GitContext = {
branch: string; // 当前分支。
changedFiles: string[]; // 已修改但未必 staged 的文件。
stagedFiles: string[]; // 已暂存文件。
untrackedFiles: string[];// Git 还没跟踪的新文件。
};注入到上下文:
代码块收起展开
Git status:
- branch: feature/agent-cli
- modified: src/queryEngine.ts
- untracked: docs/notes.md这样模型会优先关注当前改动,不会无视用户已有修改。
搜索工具描述怎么写
给模型的工具描述要带策略:
代码块收起展开
Use grep when you know a symbol, error message, function name, or config key.
Use glob when you know a filename pattern.
Prefer grep/glob before reading many files.
Do not read large generated files unless necessary.模型调用工具的质量,很依赖工具描述。
搜索失败如何反馈
坏反馈:
代码块收起展开
No matches.好反馈:
代码块收起展开
No matches for "QueryEngine" in src/**/*.ts.
Suggestions:
- Try case-insensitive search.
- Search for "query" or "runAgent".
- Use glob to inspect candidate files.这样模型有下一步。
搜索系统常见坑
| 坑 | 后果 | 修复 |
|---|---|---|
| 搜 node_modules | 输出爆炸 | ignore 默认目录 |
| 不限制结果 | 撑爆上下文 | limit + total |
| 无排序 | 重要文件淹没 | score |
| 只支持全文读 | token 浪费 | 行号读取 |
| 无 git context | 覆盖用户改动 | 注入 status |
| grep 输出无行号 | 后续编辑困难 | path:line:text |
读源码时看搜索系统
重点找:
- glob/grep 是否分开。
- 是否默认忽略构建目录。
- 输出是否有限制。
- 是否带行号。
- 是否有排序。
- 是否注入 git status。
- 是否支持范围读取。
- 搜索失败是否给模型可恢复信息。
搜索系统决定 Agent 是“会找上下文”,还是“靠猜”。
搜索不是 grep,而是 Context Retrieval
在 Agent CLI 里,搜索系统的真正职责不是“把匹配行列出来”,而是把一个模糊任务变成足够可靠的代码上下文。
用户请求通常长这样:
- “这个接口 500 了,帮我修。”
- “把登录改成手机号验证码。”
- “review 一下这次改动。”
- “为什么这个测试过不了?”
这些请求里未必包含文件名、类名、函数名。搜索系统要做的是:
所以可以把它叫作 ContextRetriever:
代码块收起展开
// ContextRetriever = 上下文检索器接口。
// 输入是用户任务和工作区信息,输出是“模型可以行动的证据包”。
type ContextRetriever = {
// retrieve 不追求返回最多内容,而是返回最相关、最省 token 的内容。
retrieve(task: UserTask, ctx: WorkspaceContext): Promise<ContextPackage>;
};它的目标不是返回最多结果,而是在 token 预算内返回“最可能让模型做对事”的证据。
任务类型决定搜索策略
不同任务的搜索入口不同。
| 任务类型 | 第一优先搜索 | 第二优先搜索 | 需要补充 |
|---|---|---|---|
| 报错修复 | error message / stack trace | test name / route | 失败日志、相关测试 |
| 功能修改 | feature keyword | controller/service/model | 相邻实现、测试样例 |
| 代码 review | git diff | changed files symbols | 调用方、测试覆盖 |
| 重构 | symbol search | references | 类型定义、边界接口 |
| 配置问题 | config key / env var | docs / scripts | 启动命令、默认值 |
| UI 问题 | visible text / component name | route / page file | screenshot、CSS |
| Agent 行为问题 | trace span / tool name | state machine | prompts、tool schema |
一个简单分类器:
代码块收起展开
// classifyTask = 教学版任务分类器。
// 它决定后续搜索策略:bugfix 优先看错误栈,review 优先看 git diff。
function classifyTask(input: string): TaskKind {
// 统一小写,方便匹配英文关键词。
const lower = input.toLowerCase();
// 报错/失败类任务。
if (lower.includes("error") || lower.includes("exception") || lower.includes("失败")) return "bugfix";
// 代码审查类任务。
if (lower.includes("review") || lower.includes("代码审查")) return "review";
// 重构类任务。
if (lower.includes("重构") || lower.includes("rename")) return "refactor";
// 配置/环境变量类任务。
if (lower.includes("配置") || lower.includes("env")) return "config";
// 默认按功能修改处理。
return "feature";
}真实系统可以让模型分类,但第一版用规则足够。
候选生成:召回要宽,读取要窄
搜索分两步:
- 候选生成:尽量不要漏。
- 上下文读取:严格控制 token。
候选生成可以很宽:
代码块收起展开
// Candidate = 一个“可能相关文件”的候选项。
// 注意它还不是最终上下文,只是待排序、待读取的候选。
type Candidate = {
path: string; // 文件路径。
reason: string; // 为什么被召回,方便 trace 和调试。
scoreSignals: Record<string, number>; // 打分信号,例如 gitChanged=1、stackTrace=1。
};
// generateCandidates = 候选召回阶段。
// 原则:召回可以宽一点,后面排序和预算读取会负责收窄。
async function generateCandidates(task: UserTask, ctx: WorkspaceContext): Promise<Candidate[]> {
const candidates: Candidate[] = [];
// 用户当前改动过的文件通常很相关。
candidates.push(...await fromGitChangedFiles(ctx));
// 如果用户贴了错误栈,从栈里的文件路径召回。
candidates.push(...await fromErrorStack(task));
// 从用户输入里抽关键词做 grep。
candidates.push(...await fromGrepKeywords(task));
// 从用户输入里抽可能的文件名/模块名做 glob。
candidates.push(...await fromFileName(task));
// 最近会话读过/改过的文件,也可能继续相关。
candidates.push(...await fromRecentSessionFiles(ctx));
// 测试名、失败用例名常常能直接定位预期行为。
candidates.push(...await fromTestNames(task));
// 同一个文件可能被多种方式召回,要合并信号而不是重复返回。
return dedupeCandidates(candidates);
}但读取阶段必须窄:
代码块收起展开
// ReadBudget = 上下文读取预算。
// 候选可以很多,但真正读进模型的内容必须受控。
type ReadBudget = {
maxFiles: number; // 最多读几个文件。
maxTotalTokens: number; // 这些文件切片总共最多多少 token。
maxLinesPerFile: number;// 单个文件最多读多少行。
};不要把候选文件全部读进上下文。候选只是地图,不是上下文。
精排:让重要文件浮上来
一个实用评分模型:
代码块收起展开
// rankCandidate = 对候选文件精排。
// 它把多个信号加权,得到“应该先读哪个文件”的分数。
function rankCandidate(c: Candidate, task: UserTask): number {
let score = 0;
// 错误栈直接命中的文件,优先级最高。
score += 10 * (c.scoreSignals.stackTrace ?? 0);
// 当前 Git 改动文件也很关键,避免无视用户已有修改。
score += 8 * (c.scoreSignals.gitChanged ?? 0);
// 文件名匹配通常比正文偶然命中更强。
score += 6 * (c.scoreSignals.fileNameMatch ?? 0);
// 符号名匹配,例如函数/类/接口名命中。
score += 5 * (c.scoreSignals.symbolMatch ?? 0);
// 测试文件说明预期行为,修 bug 时很有价值。
score += 3 * (c.scoreSignals.testFile ?? 0);
// 最近读过的文件有连续任务相关性。
score += 2 * (c.scoreSignals.recentFile ?? 0);
// 生成文件、依赖目录、构建产物通常不该优先读。
if (isGenerated(c.path)) score -= 20;
if (isVendored(c.path)) score -= 20;
// 巨大文件降低优先级,除非有更强信号。
if (isHugeFile(c.path)) score -= 5;
if (pathLooksLikeBuildOutput(c.path)) score -= 15;
return score;
}注意:测试文件不是低价值文件。
很多修 bug 任务里,测试文件比实现文件更能说明预期行为。
搜索结果应该可解释
不要只给模型:
代码块收起展开
["src/a.ts", "src/b.ts"]更好的格式:
代码块收起展开
Candidate files:
1. src/auth/loginService.ts
score: 22
reasons: symbol match "login", route keyword "auth", referenced by failing test
2. test/auth/loginService.test.ts
score: 18
reasons: failing test name, assertion mentions "phone code"可解释性有两个好处:
- 模型知道为什么读这个文件。
- Trace 里能复盘为什么漏读或误读。
Symbol Index:从全文搜索升级到结构搜索
全文 grep 找字符串,Symbol Index 找结构。
代码块收起展开
// SymbolRecord = 代码里的一个“符号”。
// 符号可以是类、函数、方法、接口、常量等,是结构搜索的基础。
type SymbolRecord = {
name: string; // 符号名,例如 LoginService、handleSubmit。
kind: "class" | "function" | "method" | "interface" | "type" | "constant"; // 符号类型。
path: string; // 所在文件。
startLine: number; // 定义开始行。
endLine: number; // 定义结束行。
signature?: string; // 函数签名/方法签名,帮助模型快速理解入参返回。
exports?: boolean; // 是否对外导出。导出符号影响面更大。
};来源可以逐步升级:
| 阶段 | 实现方式 | 适合 |
|---|---|---|
| 入门 | regex 抽 function/class | 小项目 |
| 中级 | tree-sitter | 多语言结构解析 |
| 高级 | LSP / tsserver / javac | 引用、定义、类型 |
| 工程级 | 索引服务 + 增量更新 | 大仓库 |
对于 Java 后端项目,最好能索引:
- Controller 路由方法。
- Service 方法。
- Repository / Mapper。
- DTO / Entity。
- 配置类和配置项。
- 测试类和测试方法。
这样用户说“登录接口”,Agent 能从路由跳到 Service,再跳到测试。
Call Graph:知道谁调用谁
只读当前函数常常不够。Agent 还要知道调用关系。
代码块收起展开
// CallGraphEdge = 调用图的一条边。
// 表示 from 这个符号通过某种关系依赖 to 这个符号。
type CallGraphEdge = {
from: SymbolRef; // 调用方/依赖方。
to: SymbolRef; // 被调用方/被依赖方。
kind: "call" | "new" | "implements" | "extends" | "route"; // 关系类型。
};最常用的两个查询:
代码块收起展开
findReferences(symbol): SymbolRef[]
findCallees(symbol): SymbolRef[]调 bug 时常用:
代码块收起展开
入口 Controller
-> Service 方法
-> Repository/Client
-> 异常发生点做重构时常用:
代码块收起展开
要改的函数
-> 所有调用方
-> 所有测试
-> 对外导出位置没有 call graph,Agent 很容易只改定义,不改调用方。
Code Slicing:不要整文件阅读
大文件最浪费 token。更好的方式是切片。
代码块收起展开
// CodeSlice = 真正喂给模型的一段代码证据。
// 它不是整文件,而是“路径 + 行号 + 片段 + 为什么读它”。
type CodeSlice = {
path: string; // 文件路径。
startLine: number; // 片段开始行。
endLine: number; // 片段结束行。
text: string; // 片段文本。
reason: string; // 为什么这个片段相关,例如 around matched line 42。
symbols: string[]; // 片段里涉及的符号。
};切片策略:
| 情况 | 切片方式 |
|---|---|
| grep 命中某行 | 命中行前后 30 行 |
| 命中函数名 | 整个函数 |
| 命中类名 | 类签名 + 相关方法 |
| 测试失败 | 失败测试方法 + beforeEach/helper |
| 配置项 | 配置定义 + 使用点 |
| 路由 | 路由方法 + service 调用 |
示例:
代码块收起展开
// sliceAroundLine = 围绕某个命中行读取上下文。
// 常用于 grep 命中后,给模型看命中行前后若干行。
function sliceAroundLine(file: TextFile, line: number, radius = 30): CodeSlice {
// start/end 是 1-based 行号,注意不要越界。
const start = Math.max(1, line - radius);
const end = Math.min(file.lines.length, line + radius);
return {
path: file.path, // 来源文件。
startLine: start, // 片段开始行。
endLine: end, // 片段结束行。
// 数组 slice 用 0-based,所以 start 要减 1。
text: file.lines.slice(start - 1, end).join("\n"),
// reason 会进入 trace,方便复盘为什么读这段。
reason: `around matched line ${line}`,
// 找出这段范围内有哪些函数/类,给模型更强结构感。
symbols: findSymbolsInRange(file.symbols, start, end)
};
}Context Package 格式
最终给模型的上下文不要是散乱文本,应该是结构化包。
代码块收起展开
// ContextPackage = 一次检索最终交给模型的上下文包。
// 它把任务、工作区、证据、缺口都结构化,避免散乱粘贴。
type ContextPackage = {
task: string; // 用户任务原文。
workspace: {
cwd: string; // 当前项目根目录。
branch?: string; // 当前 Git 分支。
changedFiles: string[]; // 已改文件,提醒模型不要覆盖用户改动。
};
evidence: EvidenceBlock[]; // 已读取的证据块。
openQuestions: string[]; // 仍缺失的信息,例如“还没找到测试命令”。
omitted: {
reason: string; // 为什么没放进上下文,例如 token budget。
count: number; // 被省略的数量。
}[];
};
// EvidenceBlock = 给模型看的一个证据块。
type EvidenceBlock = {
id: string; // 证据 ID,例如 E1,方便模型引用。
path: string; // 来源文件。
lines?: [number, number]; // 行号范围。
reason: string; // 为什么这段证据相关。
content: string; // 证据正文。
};模型提示里可以这样写:
代码块收起展开
Use the evidence blocks below. When proposing an edit, cite the evidence id
that justifies the change. If evidence is insufficient, ask for or retrieve
more context before editing.这会显著降低“没看够就改”的概率。
Hybrid Search:关键词 + 语义
Embedding 搜索适合“意思相近但词不一样”的场景,但不适合完全替代 grep。
| 搜索方式 | 强项 | 弱项 |
|---|---|---|
| grep | 精确符号、错误信息、配置 key | 不理解语义 |
| glob | 文件名定位 | 不看内容 |
| symbol | 定义/引用/类型 | 建索引成本高 |
| embedding | 语义相近、文档说明 | 对变量名和精确错误弱 |
| git context | 当前修改相关 | 只覆盖已有变更 |
成熟做法是 hybrid:
代码块收起展开
// Hybrid Search 教学版:
// lexical 负责精确词,semantic 负责语义相近,symbols 负责代码结构。
const lexical = await grepSearch(query);
const semantic = await vectorSearch(query);
const symbols = await symbolSearch(query);
// RRF 把多个搜索器的排名融合,避免单一搜索器误判。
const merged = reciprocalRankFusion([lexical, semantic, symbols]);RRF 的直觉:多个搜索器都觉得相关的结果,排名更高。
代码块收起展开
// rrf = Reciprocal Rank Fusion 的单项得分。
// rank 越靠前,得分越高;k 用来平滑,避免第一名压倒一切。
function rrf(rank: number, k = 60) {
return 1 / (k + rank);
}搜索失败不是终点
搜索失败要变成策略调整。
代码块收起展开
// SearchFailure = 搜索失败时返回给模型的结构化信息。
// 目的:让模型知道已经试过什么,下一步该换什么关键词/范围。
type SearchFailure = {
query: string; // 原始搜索词。
scope: string; // 搜索范围,例如 src/**/*.ts。
attempts: Array<{
tool: "grep" | "glob" | "symbol" | "semantic"; // 用了哪个搜索器。
query: string; // 这次实际搜索词。
resultCount: number; // 返回数量。
}>;
suggestions: string[]; // 建议下一步,例如换同义词、扩大范围、先看入口文件。
};例子:
代码块收起展开
No matches for "QueryEngine" in src/**/*.ts.
Tried:
- grep "QueryEngine": 0
- glob "**/*Query*": 0
Suggestions:
- search "engine"
- search "runAgent"
- inspect package entrypointsAgent 下一轮就不会原地打转。
上下文预算分配
搜索系统要配合上下文预算。
一个实用分配:
| 内容 | 预算比例 |
|---|---|
| 系统规则 / 工具说明 | 15% |
| 用户任务与历史 | 10% |
| Git 状态 | 5% |
| 搜索证据 | 45% |
| 相关测试/日志 | 15% |
| 预留给模型输出 | 10% |
如果证据太多,优先级:
代码块收起展开
错误栈命中文件
> 当前 git 改动文件
> 测试文件
> 入口文件
> 被调用实现
> 文档
> 历史相似文件评估搜索质量
搜索系统也需要 eval。
| 指标 | 含义 |
|---|---|
| recall@k | 正确文件是否出现在前 k 个候选 |
| MRR | 正确文件排名是否靠前 |
| token cost | 为找到上下文花了多少 token |
| edit success rate | 检索后修改是否一次成功 |
| re-read rate | 是否频繁因为上下文不足重读 |
| false context rate | 读了很多无关文件 |
可以造一个小型 benchmark:
代码块收起展开
// RetrievalCase = 检索评估用例。
// 用来测试“给定任务时,retriever 能不能把正确文件排到前面”。
type RetrievalCase = {
task: string; // 用户任务描述。
expectedFiles: string[]; // 正确答案应该包含的文件。
expectedSymbols?: string[];// 可选:正确答案应该包含的函数/类。
};测试:
代码块收起展开
// 这个测试验证:用户说“手机号验证码登录”时,检索器能找到实现和测试。
it("retrieves auth service for phone login task", async () => {
// 执行检索。
const result = await retriever.retrieve({
text: "把登录改成手机号验证码"
}, workspace);
// 前 5 个候选里应该有业务实现。
expect(topPaths(result, 5)).toContain("src/auth/loginService.ts");
// 前 5 个候选里也应该有测试,测试说明预期行为。
expect(topPaths(result, 5)).toContain("test/auth/loginService.test.ts");
});索引生命周期与新鲜度
搜索系统最容易被低估的问题不是“搜不到”,而是“搜到了旧东西”。Agent 如果拿旧代码、旧笔记、旧测试当证据,后面的推理越认真,错得越有说服力。
生产级检索要把索引看成有生命周期的工程对象:
| 阶段 | 做什么 | 关键字段 | 失败表现 |
|---|---|---|---|
| Discover | 找到可索引文件 | path、language、size、ignored | 把 vendor/build/cache 放进索引 |
| Parse | 提取文本、符号、行号 | symbol、range、heading、hash | 行号错、函数边界错 |
| Chunk | 切分上下文单元 | chunkId、parentPath、startLine、endLine | chunk 太碎或跨语义边界 |
| Embed / Index | 写入向量/关键词/符号索引 | contentHash、indexVersion、embeddedAt | 索引滞后、版本混用 |
| Retrieve | 召回候选 | query、retriever、score、topK | 正确文件不在候选里 |
| Validate | 校验新鲜度和可读性 | fileHashNow、fileHashIndexed、exists | 文件已删、内容已改 |
| Package | 生成上下文包 | selectedReason、tokenCost、sourceRange | 模型拿不到可引用证据 |
索引记录要能回答“这段证据是不是当前文件里的这段内容”:
代码块收起展开
// IndexedChunk = 被检索系统持久化的一段上下文。
// 它必须能在检索后重新校验,不能只保存一段裸文本。
type IndexedChunk = {
chunkId: string; // 稳定 chunk ID,用于 trace 和 eval。
sourcePath: string; // 源文件路径。
language: string; // 文件语言,用于选择 parser/ranker。
startLine: number; // chunk 在源文件中的起始行。
endLine: number; // chunk 在源文件中的结束行。
contentHash: string; // chunk 内容 hash,用于发现旧索引。
fileHash: string; // 整个文件 hash,用于快速判断文件是否变过。
indexVersion: string; // 索引器版本,parser/chunker 改动后必须更新。
symbols: string[]; // chunk 内出现的函数、类、接口、标题。
updatedAt: string; // 写入索引的时间。
};检索前后的新鲜度策略:
| 场景 | 策略 |
|---|---|
| 文件不存在 | 丢弃候选,并在 trace 记录 stale candidate |
| 文件 hash 变化 | 重新读取当前行范围;如果行范围失效,触发局部重建 |
| indexVersion 过旧 | 降低得分或强制重建 |
| chunk hash 不一致 | 不把旧 chunk 直接给模型 |
| git dirty file | 优先读 working tree,而不是上次索引内容 |
| 大仓库冷启动 | 先用关键词/文件名检索,后台补 embedding |
最关键的 trace 不是“搜到了什么”,而是“为什么信这个结果”:
代码块收起展开
{
"phase": "retrieve_validate", // 检索后的证据校验阶段。
"query": "修复登录接口 500", // 用户任务或改写后的查询。
"candidateChunkId": "auth-LoginService-42", // 初召回候选。
"sourcePath": "src/auth/LoginService.java", // 候选来源。
"indexedHash": "sha256:old", // 索引中记录的 hash。
"currentHash": "sha256:new", // 当前文件实时 hash。
"decision": "re-read-current-file", // 因为 hash 变化,所以重新读取当前文件。
"selected": true // 校验后是否进入最终上下文。
}Agent 读源码时的检索质量,不只看 recall@k,还要看新鲜度:
| 指标 | 意义 |
|---|---|
| stale candidate rate | 召回结果里有多少已经过期 |
| revalidation hit rate | 重新校验后仍然可用的比例 |
| dirty-file priority | 当前修改文件是否优先进入上下文 |
| source range accuracy | 引用行号是否仍指向正确代码 |
| index rebuild latency | 文件修改后多久进入新索引 |
这套机制的价值是防止 Agent 用“过期证据”生成看似合理的错误答案。
Java 后端项目的检索套路
如果目标是 Spring Boot 项目,可以固定几类检索入口:
| 用户说法 | 搜索入口 |
|---|---|
| “接口报错” | @RequestMapping / @GetMapping / @PostMapping |
| “某个字段不对” | DTO 字段、Entity 字段、Mapper XML |
| “查库结果不对” | Repository / Mapper / SQL XML |
| “权限问题” | Filter / Interceptor / SecurityConfig |
| “事务问题” | @Transactional、Service 调用链 |
| “参数校验” | @Valid、Validator、DTO annotation |
| “配置不生效” | application.yml、@ConfigurationProperties |
搜索系统可以内置语言画像:
代码块收起展开
// LanguageProfile = 针对不同语言/框架的检索画像。
// Spring Boot、Node、Python 的入口文件和生成目录都不一样,不能一套规则打天下。
type LanguageProfile = {
routePatterns: RegExp[]; // 路由入口模式,例如 Spring 的 @GetMapping。
testPatterns: string[]; // 测试文件命名,例如 **/*Test.java。
generatedDirs: string[]; // 生成目录,例如 target/、dist/。
configFiles: string[]; // 常见配置文件,例如 application.yml。
};这样 Java 项目、Node 项目、Python 项目可以用不同策略。
读源码时的高阶问题
读搜索模块时,继续追问:
- 搜索器只是工具,还是有 retriever 调度层?
- 候选生成和上下文读取是否分离?
- 是否记录为什么选择某个文件?
- 是否支持符号索引?
- 是否知道 git changed files?
- 是否能围绕错误栈定位文件?
- 是否有上下文预算?
- 是否有“搜索失败后的下一步建议”?
- 是否评估 recall@k?
- 是否避免读取 vendor/generated/build 输出?
真正强的 Agent 不是“会 grep”,而是知道什么时候 grep、grep 什么、读多少、把什么证据交给模型。